f
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -236,4 +236,7 @@ scripts/mm.ps1
|
|||||||
scripts/mm
|
scripts/mm
|
||||||
.style.yapf
|
.style.yapf
|
||||||
.yapfignore
|
.yapfignore
|
||||||
tmp_*
|
tmp_*
|
||||||
|
*.secret
|
||||||
|
# Ignore local ZeroTier auth tokens (project-local copy)
|
||||||
|
authtoken.secret
|
||||||
312
API/zerotier.py
312
API/zerotier.py
@@ -18,12 +18,13 @@ Example usage:
|
|||||||
if zerotier.is_available():
|
if zerotier.is_available():
|
||||||
nets = zerotier.list_networks()
|
nets = zerotier.list_networks()
|
||||||
zerotier.join_network("8056c2e21c000001")
|
zerotier.join_network("8056c2e21c000001")
|
||||||
services = zerotier.discover_services_on_network("8056c2e21c000001", ports=[5000], paths=["/health","/api_version"]) # noqa: E501
|
services = zerotier.discover_services_on_network("8056c2e21c000001", ports=[999], paths=["/health","/api_version"]) # noqa: E501
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -64,8 +65,172 @@ class ZeroTierServiceProbe:
|
|||||||
service_hint: Optional[str] = None
|
service_hint: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cli_path() -> Optional[str]:
|
||||||
|
"""Find the zerotier-cli binary or script across common locations."""
|
||||||
|
# 1. Check PATH
|
||||||
|
p = shutil.which("zerotier-cli")
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
|
||||||
|
# 2. Check common installation paths
|
||||||
|
candidates = []
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Check various Program Files locations and both .bat and .exe
|
||||||
|
roots = [
|
||||||
|
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
|
||||||
|
os.environ.get("ProgramFiles", r"C:\Program Files"),
|
||||||
|
os.environ.get("ProgramData", r"C:\ProgramData"),
|
||||||
|
]
|
||||||
|
for root in roots:
|
||||||
|
base = os.path.join(root, "ZeroTier", "One", "zerotier-cli")
|
||||||
|
candidates.append(base + ".bat")
|
||||||
|
candidates.append(base + ".exe")
|
||||||
|
else:
|
||||||
|
# Linux / macOS
|
||||||
|
candidates = [
|
||||||
|
"/usr/sbin/zerotier-cli",
|
||||||
|
"/usr/local/bin/zerotier-cli",
|
||||||
|
"/sbin/zerotier-cli",
|
||||||
|
"/var/lib/zerotier-one/zerotier-cli",
|
||||||
|
]
|
||||||
|
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
if os.path.isfile(c):
|
||||||
|
return str(c)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_home_path() -> Optional[str]:
|
||||||
|
"""Return the ZeroTier home directory (containing authtoken.secret)."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
path = os.path.join(os.environ.get("ProgramData", r"C:\ProgramData"), "ZeroTier", "One")
|
||||||
|
if os.path.isdir(path):
|
||||||
|
return path
|
||||||
|
else:
|
||||||
|
# Linux
|
||||||
|
if os.path.isdir("/var/lib/zerotier-one"):
|
||||||
|
return "/var/lib/zerotier-one"
|
||||||
|
# macOS
|
||||||
|
if os.path.isdir("/Library/Application Support/ZeroTier/One"):
|
||||||
|
return "/Library/Application Support/ZeroTier/One"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_authtoken() -> Optional[str]:
|
||||||
|
"""Try to read the local ZeroTier authtoken.secret from the ZeroTier home dir."""
|
||||||
|
home = _get_home_path()
|
||||||
|
if home:
|
||||||
|
token_file = os.path.join(home, "authtoken.secret")
|
||||||
|
if os.path.isfile(token_file):
|
||||||
|
try:
|
||||||
|
with open(token_file, "r") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_token_file(path: str) -> Optional[str]:
|
||||||
|
"""Read a token from an arbitrary file path (safely).
|
||||||
|
|
||||||
|
Returns the stripped token string or None on error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
t = f.read().strip()
|
||||||
|
return t if t else None
|
||||||
|
except Exception as exc:
|
||||||
|
debug(f"read_token_file failed: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_file_upwards(filename: str, start: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Search for `filename` by walking up parent directories starting at `start` (or CWD).
|
||||||
|
|
||||||
|
Returns the first matching path or None.
|
||||||
|
"""
|
||||||
|
start_dir = Path(start or os.getcwd()).resolve()
|
||||||
|
for p in [start_dir] + list(start_dir.parents):
|
||||||
|
candidate = p / filename
|
||||||
|
if candidate.is_file():
|
||||||
|
return str(candidate)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_repo_root(start: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Find a probable repository root by looking for .git/pyproject.toml/setup.py upwards from start.
|
||||||
|
|
||||||
|
Returns the directory path or None.
|
||||||
|
"""
|
||||||
|
start_dir = Path(start or Path(__file__).resolve().parent).resolve()
|
||||||
|
for p in [start_dir] + list(start_dir.parents):
|
||||||
|
if (p / ".git").exists() or (p / "pyproject.toml").exists() or (p / "setup.py").exists():
|
||||||
|
return str(p)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_token_path() -> Optional[str]:
|
||||||
|
"""Return the source of an auth token: 'env' or a filesystem path to authtoken.secret.
|
||||||
|
|
||||||
|
This checks in order: env token string, env token file, CWD (and parents), repo root,
|
||||||
|
user home, and finally the system ZeroTier home.
|
||||||
|
"""
|
||||||
|
# 1: token provided directly in env
|
||||||
|
if os.environ.get("ZEROTIER_AUTH_TOKEN") or os.environ.get("ZEROTIER_AUTHTOKEN"):
|
||||||
|
return "env"
|
||||||
|
|
||||||
|
# 2: token file path provided
|
||||||
|
p = os.environ.get("ZEROTIER_AUTH_TOKEN_FILE") or os.environ.get("ZEROTIER_AUTHTOKEN_FILE")
|
||||||
|
if p and os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
|
||||||
|
# 3: token file in current working dir or any parent
|
||||||
|
up = _find_file_upwards("authtoken.secret", start=os.getcwd())
|
||||||
|
if up:
|
||||||
|
return up
|
||||||
|
|
||||||
|
# 4: token file at repository root (helpful if TUI runs with a different CWD)
|
||||||
|
repo = _find_repo_root()
|
||||||
|
if repo:
|
||||||
|
rp = os.path.join(repo, "authtoken.secret")
|
||||||
|
if os.path.isfile(rp):
|
||||||
|
return rp
|
||||||
|
|
||||||
|
# 5: token file in user's home
|
||||||
|
home_candidate = os.path.join(str(Path.home()), "authtoken.secret")
|
||||||
|
if os.path.isfile(home_candidate):
|
||||||
|
return home_candidate
|
||||||
|
|
||||||
|
# 6: fallback to the ZeroTier home location
|
||||||
|
zhome = _get_home_path()
|
||||||
|
if zhome:
|
||||||
|
tz = os.path.join(zhome, "authtoken.secret")
|
||||||
|
if os.path.isfile(tz):
|
||||||
|
return tz
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_token_override() -> Optional[str]:
|
||||||
|
"""Read the token value using the path determined by `_get_token_path()` or env.
|
||||||
|
|
||||||
|
Returns the token string, or None if no token is available.
|
||||||
|
"""
|
||||||
|
path_or_env = _get_token_path()
|
||||||
|
if path_or_env == "env":
|
||||||
|
t = os.environ.get("ZEROTIER_AUTH_TOKEN") or os.environ.get("ZEROTIER_AUTHTOKEN")
|
||||||
|
return t.strip() if t else None
|
||||||
|
if path_or_env:
|
||||||
|
return _read_token_file(path_or_env)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _cli_available() -> bool:
|
def _cli_available() -> bool:
|
||||||
return bool(shutil.which("zerotier-cli"))
|
return _get_cli_path() is not None
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
def is_available() -> bool:
|
||||||
@@ -73,23 +238,49 @@ def is_available() -> bool:
|
|||||||
return _HAVE_PY_ZEROTIER or _cli_available()
|
return _HAVE_PY_ZEROTIER or _cli_available()
|
||||||
|
|
||||||
|
|
||||||
def _run_cli_json(*args: str, timeout: float = 5.0) -> Any:
|
def _run_cli_capture(*args: str, timeout: float = 5.0) -> Tuple[int, str, str]:
|
||||||
"""Run zerotier-cli with arguments and parse JSON output if possible.
|
"""Run zerotier-cli and return (returncode, stdout, stderr).
|
||||||
|
|
||||||
Returns parsed JSON on success, or raises an exception.
|
This centralizes how we call the CLI so we can always capture stderr and
|
||||||
|
returncodes and make debugging failures much easier.
|
||||||
"""
|
"""
|
||||||
bin_path = shutil.which("zerotier-cli")
|
bin_path = _get_cli_path()
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
raise RuntimeError("zerotier-cli not found")
|
raise RuntimeError("zerotier-cli not found")
|
||||||
|
|
||||||
cmd = [bin_path, *args]
|
full_args = list(args)
|
||||||
|
token = _get_token_override()
|
||||||
|
if token and not any(a.startswith("-T") for a in full_args):
|
||||||
|
# Do not log the token itself; we log only its presence/length for debugging
|
||||||
|
debug(f"Using external authtoken (len={len(token)}) for CLI auth")
|
||||||
|
full_args.insert(0, f"-T{token}")
|
||||||
|
|
||||||
|
home = _get_home_path()
|
||||||
|
if home and not any(a.startswith("-D") for a in full_args):
|
||||||
|
full_args.insert(0, f"-D{home}")
|
||||||
|
|
||||||
|
cmd = [bin_path, *full_args]
|
||||||
debug(f"Running zerotier-cli: {cmd}")
|
debug(f"Running zerotier-cli: {cmd}")
|
||||||
out = subprocess.check_output(cmd, timeout=timeout)
|
use_shell = sys.platform == "win32" and str(bin_path).lower().endswith(".bat")
|
||||||
|
proc = subprocess.run(cmd, timeout=timeout, capture_output=True, text=True, shell=use_shell)
|
||||||
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli_json(*args: str, timeout: float = 5.0) -> Any:
|
||||||
|
"""Run zerotier-cli with arguments and parse JSON output if possible.
|
||||||
|
|
||||||
|
Returns parsed JSON on success, or raises an exception with stderr when non-zero exit.
|
||||||
|
"""
|
||||||
|
rc, out, err = _run_cli_capture(*args, timeout=timeout)
|
||||||
|
if rc != 0:
|
||||||
|
# Surface stderr or stdout in the exception so callers (and logs) can show
|
||||||
|
# the actionable message instead of a blind CalledProcessError.
|
||||||
|
raise RuntimeError(f"zerotier-cli failed (rc={rc}): {err.strip() or out.strip()}")
|
||||||
try:
|
try:
|
||||||
return json.loads(out.decode("utf-8"))
|
return json.loads(out)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Some CLI invocations might print non-json; return as raw string
|
# Some CLI invocations might print non-json; return as raw string
|
||||||
return out.decode("utf-8", "replace")
|
return out
|
||||||
|
|
||||||
|
|
||||||
def list_networks() -> List[ZeroTierNetwork]:
|
def list_networks() -> List[ZeroTierNetwork]:
|
||||||
@@ -103,14 +294,21 @@ def list_networks() -> List[ZeroTierNetwork]:
|
|||||||
try:
|
try:
|
||||||
# Attempt to use common API shape (best-effort)
|
# Attempt to use common API shape (best-effort)
|
||||||
raw = _zt.list_networks() # type: ignore[attr-defined]
|
raw = _zt.list_networks() # type: ignore[attr-defined]
|
||||||
for n in raw or []:
|
# If the Python binding returned results, use them. If it returned
|
||||||
nets.append(ZeroTierNetwork(
|
# an empty list/None, fall back to the CLI so we don't return a
|
||||||
id=str(n.get("id") or n.get("networkId") or ""),
|
# false-empty result to the UI.
|
||||||
name=str(n.get("name") or ""),
|
if raw:
|
||||||
status=str(n.get("status") or ""),
|
for n in raw:
|
||||||
assigned_addresses=list(n.get("assignedAddresses") or []),
|
# raw entries are expected to be dict-like
|
||||||
))
|
nets.append(ZeroTierNetwork(
|
||||||
return nets
|
id=str(n.get("id") or n.get("networkId") or ""),
|
||||||
|
name=str(n.get("name") or ""),
|
||||||
|
status=str(n.get("status") or ""),
|
||||||
|
assigned_addresses=list(n.get("assignedAddresses") or []),
|
||||||
|
))
|
||||||
|
return nets
|
||||||
|
else:
|
||||||
|
debug("py-zerotier returned no networks; falling back to CLI")
|
||||||
except Exception as exc: # pragma: no cover - optional dependency
|
except Exception as exc: # pragma: no cover - optional dependency
|
||||||
debug(f"py-zerotier listing failed: {exc}")
|
debug(f"py-zerotier listing failed: {exc}")
|
||||||
|
|
||||||
@@ -149,10 +347,15 @@ def join_network(network_id: str) -> bool:
|
|||||||
|
|
||||||
if _cli_available():
|
if _cli_available():
|
||||||
try:
|
try:
|
||||||
subprocess.check_call([shutil.which("zerotier-cli"), "join", network_id], timeout=10)
|
rc, out, err = _run_cli_capture("join", network_id, timeout=10)
|
||||||
return True
|
if rc == 0:
|
||||||
except Exception as exc:
|
return True
|
||||||
debug(f"zerotier-cli join failed: {exc}")
|
# Surface the CLI's stderr/stdout to callers as an exception so the TUI
|
||||||
|
# can show a helpful error (instead of a generic 'failed to join').
|
||||||
|
raise RuntimeError(f"zerotier-cli join failed (rc={rc}): {err.strip() or out.strip()}")
|
||||||
|
except Exception:
|
||||||
|
# Re-raise so callers (UI/tests) can react to the exact error
|
||||||
|
raise
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -170,10 +373,12 @@ def leave_network(network_id: str) -> bool:
|
|||||||
|
|
||||||
if _cli_available():
|
if _cli_available():
|
||||||
try:
|
try:
|
||||||
subprocess.check_call([shutil.which("zerotier-cli"), "leave", network_id], timeout=10)
|
rc, out, err = _run_cli_capture("leave", network_id, timeout=10)
|
||||||
return True
|
if rc == 0:
|
||||||
except Exception as exc:
|
return True
|
||||||
debug(f"zerotier-cli leave failed: {exc}")
|
raise RuntimeError(f"zerotier-cli leave failed (rc={rc}): {err.strip() or out.strip()}")
|
||||||
|
except Exception:
|
||||||
|
raise
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -199,6 +404,32 @@ def get_assigned_addresses(network_id: str) -> List[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_central_members(network_id: str, api_token: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch member details from ZeroTier Central API.
|
||||||
|
|
||||||
|
Requires a valid ZeroTier Central API token.
|
||||||
|
Returns a list of member objects containing 'config' with 'ipAssignments', etc.
|
||||||
|
"""
|
||||||
|
url = f"https://my.zerotier.com/api/v1/network/{network_id}/member"
|
||||||
|
headers = {"Authorization": f"token {api_token}"}
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
resp = httpx.get(url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
resp = requests.get(url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
debug(f"ZeroTier Central API fetch failed: {exc}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def list_peers() -> List[Dict[str, Any]]:
|
def list_peers() -> List[Dict[str, Any]]:
|
||||||
"""Return peers known to the local ZeroTier node (best-effort parsing).
|
"""Return peers known to the local ZeroTier node (best-effort parsing).
|
||||||
|
|
||||||
@@ -262,23 +493,31 @@ def discover_services_on_network(
|
|||||||
paths: Optional[List[str]] = None,
|
paths: Optional[List[str]] = None,
|
||||||
timeout: float = 2.0,
|
timeout: float = 2.0,
|
||||||
accept_json: bool = True,
|
accept_json: bool = True,
|
||||||
|
api_token: Optional[str] = None,
|
||||||
) -> List[ZeroTierServiceProbe]:
|
) -> List[ZeroTierServiceProbe]:
|
||||||
"""Probe assigned addresses on the given network for HTTP services.
|
"""Probe peers on the given network for HTTP services.
|
||||||
|
|
||||||
Returns a list of ZeroTierServiceProbe entries for successful probes.
|
If api_token is provided, it fetches all member IPs from ZeroTier Central.
|
||||||
|
Otherwise, it only probes the local node's assigned addresses (for now).
|
||||||
By default probes `ports=[5000]` (our remote_storage_server default) and
|
|
||||||
`paths=["/health","/api_version"]` which should detect either our
|
|
||||||
remote_storage_server or Hydrus instances.
|
|
||||||
"""
|
"""
|
||||||
net = str(network_id or "").strip()
|
net = str(network_id or "").strip()
|
||||||
if not net:
|
if not net:
|
||||||
raise ValueError("network_id required")
|
raise ValueError("network_id required")
|
||||||
|
|
||||||
ports = list(ports or [5000])
|
ports = list(ports or [999])
|
||||||
paths = list(paths or ["/health", "/api_version", "/api_version/", "/session_key"])
|
paths = list(paths or ["/health", "/api_version", "/api_version/", "/session_key"])
|
||||||
|
|
||||||
addresses = get_assigned_addresses(net)
|
addresses = get_assigned_addresses(net)
|
||||||
|
|
||||||
|
if api_token:
|
||||||
|
members = fetch_central_members(net, api_token)
|
||||||
|
for m in members:
|
||||||
|
# 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)
|
||||||
|
|
||||||
probes: List[ZeroTierServiceProbe] = []
|
probes: List[ZeroTierServiceProbe] = []
|
||||||
|
|
||||||
for addr in addresses:
|
for addr in addresses:
|
||||||
@@ -327,15 +566,18 @@ def find_peer_service(
|
|||||||
port: Optional[int] = None,
|
port: Optional[int] = None,
|
||||||
path_candidates: Optional[List[str]] = None,
|
path_candidates: Optional[List[str]] = None,
|
||||||
timeout: float = 2.0,
|
timeout: float = 2.0,
|
||||||
|
api_token: Optional[str] = None,
|
||||||
) -> Optional[ZeroTierServiceProbe]:
|
) -> Optional[ZeroTierServiceProbe]:
|
||||||
"""Return the first probe that matches service_hint or is successful.
|
"""Return the first probe that matches service_hint or is successful.
|
||||||
|
|
||||||
Useful for selecting a peer to configure a store against.
|
Useful for selecting a peer to configure a store against.
|
||||||
"""
|
"""
|
||||||
paths = path_candidates or ["/health", "/api_version", "/session_key"]
|
paths = path_candidates or ["/health", "/api_version", "/session_key"]
|
||||||
ports = [port] if port is not None else [5000, 45869, 80, 443]
|
ports = [port] if port is not None else [999, 5000, 45869, 80, 443]
|
||||||
|
|
||||||
probes = discover_services_on_network(network_id, ports=ports, paths=paths, timeout=timeout)
|
probes = discover_services_on_network(
|
||||||
|
network_id, ports=ports, paths=paths, timeout=timeout, api_token=api_token
|
||||||
|
)
|
||||||
if not probes:
|
if not probes:
|
||||||
return None
|
return None
|
||||||
if service_hint:
|
if service_hint:
|
||||||
|
|||||||
110
CLI.py
110
CLI.py
@@ -4415,6 +4415,116 @@ class MedeiaCLI:
|
|||||||
def repl() -> None:
|
def repl() -> None:
|
||||||
self.run_repl()
|
self.run_repl()
|
||||||
|
|
||||||
|
@app.command("remote-server")
|
||||||
|
def remote_server(
|
||||||
|
storage_path: str = typer.Argument(
|
||||||
|
None, help="Path to the store folder or store name from config"
|
||||||
|
),
|
||||||
|
port: int = typer.Option(None, "--port", help="Port to run the server on"),
|
||||||
|
api_key: str | None = typer.Option(None, "--api-key", help="API key for authentication"),
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
debug_server: bool = False,
|
||||||
|
background: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Start the remote storage Flask server.
|
||||||
|
|
||||||
|
If no path is provided, it looks for [networking=zerotier] 'serve' and 'port' in config.
|
||||||
|
'serve' can be a path or the name of a [store=folder] entry.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
mm remote-server C:\\path\\to\\store --port 999 --api-key mykey
|
||||||
|
mm remote-server my_folder_name
|
||||||
|
mm remote-server --background
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from scripts import remote_storage_server as rss
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
"Error: remote_storage_server script not available:",
|
||||||
|
exc,
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure Flask present
|
||||||
|
if not getattr(rss, "HAS_FLASK", False):
|
||||||
|
print(
|
||||||
|
"ERROR: Flask and flask-cors required; install with: pip install flask flask-cors",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
from SYS.config import load_config
|
||||||
|
|
||||||
|
conf = load_config()
|
||||||
|
|
||||||
|
# Resolve from Networking config if omitted
|
||||||
|
zt_conf = conf.get("networking", {}).get("zerotier", {})
|
||||||
|
if not storage_path:
|
||||||
|
storage_path = zt_conf.get("serve")
|
||||||
|
if port is None:
|
||||||
|
port = int(zt_conf.get("port") or 999)
|
||||||
|
if api_key is None:
|
||||||
|
api_key = zt_conf.get("api_key")
|
||||||
|
|
||||||
|
if not storage_path:
|
||||||
|
print(
|
||||||
|
"Error: No storage path provided and no [networking=zerotier] 'serve' configured.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Check if storage_path is a named folder store
|
||||||
|
folders = conf.get("store", {}).get("folder", {})
|
||||||
|
found_path = None
|
||||||
|
for name, block in folders.items():
|
||||||
|
if name.lower() == storage_path.lower():
|
||||||
|
found_path = block.get("path") or block.get("PATH")
|
||||||
|
break
|
||||||
|
|
||||||
|
if found_path:
|
||||||
|
storage = Path(found_path).resolve()
|
||||||
|
else:
|
||||||
|
storage = Path(storage_path).resolve()
|
||||||
|
|
||||||
|
if not storage.exists():
|
||||||
|
print(f"Error: Storage path does not exist: {storage}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
rss.STORAGE_PATH = storage
|
||||||
|
rss.API_KEY = api_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_obj = rss.create_app()
|
||||||
|
except Exception as exc:
|
||||||
|
print("Failed to create remote_storage_server app:", exc, file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Starting remote storage server at http://{host}:{port}, storage: {storage}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if background:
|
||||||
|
try:
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
import threading
|
||||||
|
|
||||||
|
server = make_server(host, port, app_obj)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
print(f"Server started in background (thread id={thread.ident})")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
print("Failed to start background server, falling back to foreground:", exc, file=sys.stderr)
|
||||||
|
|
||||||
|
# Foreground run blocks the CLI until server exits
|
||||||
|
try:
|
||||||
|
app_obj.run(host=host, port=port, debug=debug_server, use_reloader=False, threaded=True)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Remote server stopped by user")
|
||||||
|
|
||||||
@app.callback(invoke_without_command=True)
|
@app.callback(invoke_without_command=True)
|
||||||
def main_callback(ctx: typer.Context) -> None:
|
def main_callback(ctx: typer.Context) -> None:
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ def _apply_conf_block(
|
|||||||
tool[tool_name] = dict(block)
|
tool[tool_name] = dict(block)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if kind_l == "networking":
|
||||||
|
net_name = str(subtype).strip().lower()
|
||||||
|
if not net_name:
|
||||||
|
return
|
||||||
|
net = config.setdefault("networking", {})
|
||||||
|
if not isinstance(net, dict):
|
||||||
|
config["networking"] = {}
|
||||||
|
net = config["networking"]
|
||||||
|
existing = net.get(net_name)
|
||||||
|
if isinstance(existing, dict):
|
||||||
|
_merge_dict_inplace(existing, block)
|
||||||
|
else:
|
||||||
|
net[net_name] = dict(block)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def parse_conf_text(text: str, *, base: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def parse_conf_text(text: str, *, base: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
"""Parse a lightweight .conf format into the app's config dict.
|
"""Parse a lightweight .conf format into the app's config dict.
|
||||||
@@ -284,7 +299,7 @@ def _serialize_conf(config: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
# Top-level scalars first
|
# Top-level scalars first
|
||||||
for key in sorted(config.keys()):
|
for key in sorted(config.keys()):
|
||||||
if key in {"store", "provider", "tool"}:
|
if key in {"store", "provider", "tool", "networking"}:
|
||||||
continue
|
continue
|
||||||
value = config.get(key)
|
value = config.get(key)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
@@ -351,6 +366,24 @@ def _serialize_conf(config: Dict[str, Any]) -> str:
|
|||||||
seen_keys.add(k_upper)
|
seen_keys.add(k_upper)
|
||||||
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
||||||
|
|
||||||
|
# Networking blocks
|
||||||
|
networking = config.get("networking")
|
||||||
|
if isinstance(networking, dict):
|
||||||
|
for name in sorted(networking.keys()):
|
||||||
|
block = networking.get(name)
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"[networking={name}]")
|
||||||
|
|
||||||
|
seen_keys = set()
|
||||||
|
for k in sorted(block.keys()):
|
||||||
|
k_upper = k.upper()
|
||||||
|
if k_upper in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(k_upper)
|
||||||
|
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
||||||
|
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ _PROVIDER_DEPENDENCIES: Dict[str, List[Tuple[str, str]]] = {
|
|||||||
"soulseek": [("aioslsk", "aioslsk>=1.6.0")],
|
"soulseek": [("aioslsk", "aioslsk>=1.6.0")],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Dependencies required when ZeroTier features are configured (auto-install when enabled)
|
||||||
|
_ZEROTIER_DEPENDENCIES: List[Tuple[str, str]] = [
|
||||||
|
("flask", "flask>=2.3.0"),
|
||||||
|
("flask_cors", "flask-cors>=3.0.1"),
|
||||||
|
("werkzeug", "werkzeug>=2.3.0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def florencevision_missing_modules() -> List[str]:
|
def florencevision_missing_modules() -> List[str]:
|
||||||
return [
|
return [
|
||||||
@@ -144,5 +151,29 @@ def maybe_auto_install_configured_tools(config: Dict[str, Any]) -> None:
|
|||||||
label = f"{provider_name.title()} provider"
|
label = f"{provider_name.title()} provider"
|
||||||
_install_requirements(label, requirements)
|
_install_requirements(label, requirements)
|
||||||
|
|
||||||
|
# ZeroTier: if a zerotier section is present OR a zerotier store is configured,
|
||||||
|
# optionally auto-install Flask-based remote server dependencies so the
|
||||||
|
# `remote_storage_server.py` and CLI helper will run out-of-the-box.
|
||||||
|
try:
|
||||||
|
zerotier_cfg = (config or {}).get("zerotier")
|
||||||
|
store_cfg = (config or {}).get("store") if isinstance(config, dict) else {}
|
||||||
|
store_has_zerotier = isinstance(store_cfg, dict) and bool(store_cfg.get("zerotier"))
|
||||||
|
|
||||||
|
if (isinstance(zerotier_cfg, dict) and zerotier_cfg) or store_has_zerotier:
|
||||||
|
auto_install = True
|
||||||
|
if isinstance(zerotier_cfg, dict) and "auto_install" in zerotier_cfg:
|
||||||
|
auto_install = _as_bool(zerotier_cfg.get("auto_install"), True)
|
||||||
|
if auto_install:
|
||||||
|
missing = [
|
||||||
|
requirement
|
||||||
|
for import_name, requirement in _ZEROTIER_DEPENDENCIES
|
||||||
|
if not _try_import(import_name)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
_install_requirements("ZeroTier", missing)
|
||||||
|
except Exception:
|
||||||
|
# Don't let optional-dep logic raise at startup
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["maybe_auto_install_configured_tools", "florencevision_missing_modules"]
|
__all__ = ["maybe_auto_install_configured_tools", "florencevision_missing_modules"]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Configuration keys:
|
|||||||
- NAME: store instance name (required)
|
- NAME: store instance name (required)
|
||||||
- NETWORK_ID: ZeroTier network ID to use for discovery (required)
|
- NETWORK_ID: ZeroTier network ID to use for discovery (required)
|
||||||
- SERVICE: 'remote' or 'hydrus' (default: 'remote')
|
- SERVICE: 'remote' or 'hydrus' (default: 'remote')
|
||||||
- PORT: service port (default: 5000 for remote, 45869 for hydrus)
|
- PORT: service port (default: 999 for remote, 45869 for hydrus)
|
||||||
- API_KEY: optional API key to include in requests
|
- API_KEY: optional API key to include in requests
|
||||||
- HOST: optional preferred peer address (skip discovery if provided)
|
- HOST: optional preferred peer address (skip discovery if provided)
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ class ZeroTier(Store):
|
|||||||
{"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": "SERVICE", "label": "Service Type (remote|hydrus)", "default": "remote", "required": True},
|
||||||
{"key": "PORT", "label": "Service Port", "default": "5000", "required": False},
|
{"key": "PORT", "label": "Service Port", "default": "999", "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": "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},
|
||||||
@@ -93,7 +93,7 @@ class ZeroTier(Store):
|
|||||||
self._name = str(instance_name or "")
|
self._name = str(instance_name or "")
|
||||||
self._network_id = str(network_id or "").strip()
|
self._network_id = str(network_id or "").strip()
|
||||||
self._service = (str(service or "remote") or "remote").lower()
|
self._service = (str(service or "remote") or "remote").lower()
|
||||||
self._port = int(port if port is not None else (45869 if self._service == "hydrus" else 5000))
|
self._port = int(port if port is not None else (45869 if self._service == "hydrus" else 999))
|
||||||
self._api_key = str(api_key or "").strip() or None
|
self._api_key = str(api_key or "").strip() or None
|
||||||
self._preferred_host = str(host or "").strip() or None
|
self._preferred_host = str(host or "").strip() or None
|
||||||
self._timeout = int(timeout or 5)
|
self._timeout = int(timeout or 5)
|
||||||
@@ -123,8 +123,20 @@ class ZeroTier(Store):
|
|||||||
debug(f"ZeroTier discovery helper not available: {exc}")
|
debug(f"ZeroTier discovery helper not available: {exc}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Try to find a central API key for better discovery
|
||||||
|
from SYS.config import load_config
|
||||||
|
|
||||||
|
conf = load_config()
|
||||||
|
net_conf = conf.get("networking", {}).get("zerotier", {})
|
||||||
|
central_token = net_conf.get("api_key")
|
||||||
|
|
||||||
# Look for a matching service on the network
|
# Look for a matching service on the network
|
||||||
probe = zt.find_peer_service(self._network_id, service_hint=("hydrus" if self._service == "hydrus" else None), port=self._port)
|
probe = zt.find_peer_service(
|
||||||
|
self._network_id,
|
||||||
|
service_hint=("hydrus" if self._service == "hydrus" else None),
|
||||||
|
port=self._port,
|
||||||
|
api_token=central_token,
|
||||||
|
)
|
||||||
if probe:
|
if probe:
|
||||||
# Extract host:port
|
# Extract host:port
|
||||||
host = probe.address
|
host = probe.address
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ from TUI.modalscreen.selection_modal import SelectionModal
|
|||||||
class ConfigModal(ModalScreen):
|
class ConfigModal(ModalScreen):
|
||||||
"""A modal for editing the configuration."""
|
"""A modal for editing the configuration."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("ctrl+v", "paste", "Paste"),
|
||||||
|
("ctrl+c", "copy", "Copy"),
|
||||||
|
]
|
||||||
|
|
||||||
CSS = """
|
CSS = """
|
||||||
ConfigModal {
|
ConfigModal {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
@@ -63,8 +68,19 @@ class ConfigModal(ModalScreen):
|
|||||||
color: $accent;
|
color: $accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
height: 5;
|
||||||
|
margin-bottom: 1;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
.config-input {
|
.config-input {
|
||||||
width: 100%;
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-btn {
|
||||||
|
width: 10;
|
||||||
|
margin-left: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#config-actions {
|
#config-actions {
|
||||||
@@ -115,6 +131,7 @@ class ConfigModal(ModalScreen):
|
|||||||
yield Label("Categories", classes="config-label")
|
yield Label("Categories", classes="config-label")
|
||||||
with ListView(id="category-list"):
|
with ListView(id="category-list"):
|
||||||
yield ListItem(Label("Global Settings"), id="cat-globals")
|
yield ListItem(Label("Global Settings"), id="cat-globals")
|
||||||
|
yield ListItem(Label("Networking"), id="cat-networking")
|
||||||
yield ListItem(Label("Stores"), id="cat-stores")
|
yield ListItem(Label("Stores"), id="cat-stores")
|
||||||
yield ListItem(Label("Providers"), id="cat-providers")
|
yield ListItem(Label("Providers"), id="cat-providers")
|
||||||
|
|
||||||
@@ -124,13 +141,14 @@ class ConfigModal(ModalScreen):
|
|||||||
yield Button("Save", variant="success", id="save-btn")
|
yield Button("Save", variant="success", id="save-btn")
|
||||||
yield Button("Add Store", variant="primary", id="add-store-btn")
|
yield Button("Add Store", variant="primary", id="add-store-btn")
|
||||||
yield Button("Add Provider", variant="primary", id="add-provider-btn")
|
yield Button("Add Provider", variant="primary", id="add-provider-btn")
|
||||||
|
yield Button("Add Net", variant="primary", id="add-net-btn")
|
||||||
yield Button("Back", id="back-btn")
|
yield Button("Back", id="back-btn")
|
||||||
yield Button("Close", variant="error", id="cancel-btn")
|
yield Button("Close", variant="error", id="cancel-btn")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.query_one("#add-store-btn", Button).display = False
|
self.query_one("#add-store-btn", Button).display = False
|
||||||
self.query_one("#add-provider-btn", Button).display = False
|
self.query_one("#add-provider-btn", Button).display = False
|
||||||
self.query_one("#back-btn", Button).display = False
|
self.query_one("#add-net-btn", Button).display = False
|
||||||
self.refresh_view()
|
self.refresh_view()
|
||||||
|
|
||||||
def refresh_view(self) -> None:
|
def refresh_view(self) -> None:
|
||||||
@@ -146,6 +164,7 @@ class ConfigModal(ModalScreen):
|
|||||||
try:
|
try:
|
||||||
self.query_one("#add-store-btn", Button).display = (self.current_category == "stores" and self.editing_item_name is None)
|
self.query_one("#add-store-btn", Button).display = (self.current_category == "stores" and self.editing_item_name is None)
|
||||||
self.query_one("#add-provider-btn", Button).display = (self.current_category == "providers" and self.editing_item_name is None)
|
self.query_one("#add-provider-btn", Button).display = (self.current_category == "providers" and self.editing_item_name is None)
|
||||||
|
self.query_one("#add-net-btn", Button).display = (self.current_category == "networking" and self.editing_item_name is None)
|
||||||
self.query_one("#back-btn", Button).display = (self.editing_item_name is not None)
|
self.query_one("#back-btn", Button).display = (self.editing_item_name is not None)
|
||||||
self.query_one("#save-btn", Button).display = (self.editing_item_name is not None or self.current_category == "globals")
|
self.query_one("#save-btn", Button).display = (self.editing_item_name is not None or self.current_category == "globals")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -158,6 +177,8 @@ class ConfigModal(ModalScreen):
|
|||||||
self.render_item_editor(container)
|
self.render_item_editor(container)
|
||||||
elif self.current_category == "globals":
|
elif self.current_category == "globals":
|
||||||
self.render_globals(container)
|
self.render_globals(container)
|
||||||
|
elif self.current_category == "networking":
|
||||||
|
self.render_networking(container)
|
||||||
elif self.current_category == "stores":
|
elif self.current_category == "stores":
|
||||||
self.render_stores(container)
|
self.render_stores(container)
|
||||||
elif self.current_category == "providers":
|
elif self.current_category == "providers":
|
||||||
@@ -202,7 +223,10 @@ class ConfigModal(ModalScreen):
|
|||||||
sel = Select(select_options, value=current_val, id=inp_id)
|
sel = Select(select_options, value=current_val, id=inp_id)
|
||||||
container.mount(sel)
|
container.mount(sel)
|
||||||
else:
|
else:
|
||||||
container.mount(Input(value=current_val, id=inp_id, classes="config-input"))
|
row = Horizontal(classes="field-row")
|
||||||
|
container.mount(row)
|
||||||
|
row.mount(Input(value=current_val, id=inp_id, classes="config-input"))
|
||||||
|
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# Show any other top-level keys not in schema
|
# Show any other top-level keys not in schema
|
||||||
@@ -214,9 +238,66 @@ class ConfigModal(ModalScreen):
|
|||||||
inp_id = f"global-{idx}"
|
inp_id = f"global-{idx}"
|
||||||
self._input_id_map[inp_id] = k
|
self._input_id_map[inp_id] = k
|
||||||
container.mount(Label(k))
|
container.mount(Label(k))
|
||||||
container.mount(Input(value=str(v), id=inp_id, classes="config-input"))
|
row = Horizontal(classes="field-row")
|
||||||
|
container.mount(row)
|
||||||
|
row.mount(Input(value=str(v), id=inp_id, classes="config-input"))
|
||||||
|
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
|
def render_networking(self, container: ScrollableContainer) -> None:
|
||||||
|
container.mount(Label("ZeroTier Networks (local)", classes="config-label"))
|
||||||
|
|
||||||
|
from API import zerotier as zt
|
||||||
|
# Show whether we have an explicit authtoken available and its source
|
||||||
|
try:
|
||||||
|
token_src = zt._get_token_path()
|
||||||
|
except Exception:
|
||||||
|
token_src = None
|
||||||
|
|
||||||
|
if token_src == "env":
|
||||||
|
container.mount(Static("Auth: authtoken provided via env var (ZEROTIER_AUTH_TOKEN) — no admin required", classes="config-note"))
|
||||||
|
elif token_src:
|
||||||
|
container.mount(Static(f"Auth: authtoken file found: {token_src} — no admin required", classes="config-note"))
|
||||||
|
else:
|
||||||
|
container.mount(Static("Auth: authtoken not found in workspace; TUI may need admin to join networks", classes="config-warning"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
local_nets = zt.list_networks()
|
||||||
|
if not local_nets:
|
||||||
|
container.mount(Static("No active ZeroTier networks found on this machine."))
|
||||||
|
else:
|
||||||
|
for n in local_nets:
|
||||||
|
row = Horizontal(
|
||||||
|
Static(f"{n.name} [{n.id}] - {n.status}", classes="item-label"),
|
||||||
|
Button("Leave", variant="error", id=f"zt-leave-{n.id}"),
|
||||||
|
classes="item-row"
|
||||||
|
)
|
||||||
|
container.mount(row)
|
||||||
|
except Exception as exc:
|
||||||
|
container.mount(Static(f"Error listing ZeroTier networks: {exc}"))
|
||||||
|
|
||||||
|
container.mount(Rule())
|
||||||
|
container.mount(Label("Networking Services", classes="config-label"))
|
||||||
|
net = self.config_data.get("networking", {})
|
||||||
|
if not net:
|
||||||
|
container.mount(Static("No networking services configured."))
|
||||||
|
else:
|
||||||
|
idx = 0
|
||||||
|
for ntype, conf in net.items():
|
||||||
|
edit_id = f"edit-net-{idx}"
|
||||||
|
del_id = f"del-net-{idx}"
|
||||||
|
self._button_id_map[edit_id] = ("edit", "networking", ntype)
|
||||||
|
self._button_id_map[del_id] = ("del", "networking", ntype)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
row = Horizontal(
|
||||||
|
Static(ntype, classes="item-label"),
|
||||||
|
Button("Edit", id=edit_id),
|
||||||
|
Button("Delete", variant="error", id=del_id),
|
||||||
|
classes="item-row"
|
||||||
|
)
|
||||||
|
container.mount(row)
|
||||||
|
|
||||||
def render_stores(self, container: ScrollableContainer) -> None:
|
def render_stores(self, container: ScrollableContainer) -> None:
|
||||||
container.mount(Label("Configured Stores", classes="config-label"))
|
container.mount(Label("Configured Stores", classes="config-label"))
|
||||||
stores = self.config_data.get("store", {})
|
stores = self.config_data.get("store", {})
|
||||||
@@ -310,6 +391,23 @@ class ConfigModal(ModalScreen):
|
|||||||
provider_schema_map[k.upper()] = field_def
|
provider_schema_map[k.upper()] = field_def
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Fetch Networking schema
|
||||||
|
if item_type == "networking":
|
||||||
|
if item_name == "zerotier":
|
||||||
|
schema = [
|
||||||
|
{"key": "api_key", "label": "ZeroTier Central API Token", "default": "", "secret": True},
|
||||||
|
{"key": "network_id", "label": "Network ID to Join", "default": ""},
|
||||||
|
]
|
||||||
|
for f in schema:
|
||||||
|
provider_schema_map[f["key"].upper()] = f
|
||||||
|
|
||||||
|
# Use columns for better layout of inputs with paste buttons
|
||||||
|
container.mount(Label("Edit Settings"))
|
||||||
|
# render_item_editor will handle the inputs for us if we set these
|
||||||
|
# but wait, render_item_editor is called from refresh_view, not here.
|
||||||
|
# actually we don't need to do anything else here because refresh_view calls render_item_editor
|
||||||
|
# which now handles the paste buttons.
|
||||||
|
|
||||||
# Show all existing keys
|
# Show all existing keys
|
||||||
existing_keys_upper = set()
|
existing_keys_upper = set()
|
||||||
@@ -351,10 +449,13 @@ class ConfigModal(ModalScreen):
|
|||||||
sel = Select(select_options, value=current_val, id=inp_id)
|
sel = Select(select_options, value=current_val, id=inp_id)
|
||||||
container.mount(sel)
|
container.mount(sel)
|
||||||
else:
|
else:
|
||||||
|
row = Horizontal(classes="field-row")
|
||||||
|
container.mount(row)
|
||||||
inp = Input(value=str(v), id=inp_id, classes="config-input")
|
inp = Input(value=str(v), id=inp_id, classes="config-input")
|
||||||
if is_secret:
|
if is_secret:
|
||||||
inp.password = True
|
inp.password = True
|
||||||
container.mount(inp)
|
row.mount(inp)
|
||||||
|
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# Add required/optional fields from schema that are missing
|
# Add required/optional fields from schema that are missing
|
||||||
@@ -378,12 +479,15 @@ class ConfigModal(ModalScreen):
|
|||||||
sel = Select(select_options, value=default_val, id=inp_id)
|
sel = Select(select_options, value=default_val, id=inp_id)
|
||||||
container.mount(sel)
|
container.mount(sel)
|
||||||
else:
|
else:
|
||||||
|
row = Horizontal(classes="field-row")
|
||||||
|
container.mount(row)
|
||||||
inp = Input(value=default_val, id=inp_id, classes="config-input")
|
inp = Input(value=default_val, id=inp_id, classes="config-input")
|
||||||
if field_def.get("secret"):
|
if field_def.get("secret"):
|
||||||
inp.password = True
|
inp.password = True
|
||||||
if field_def.get("placeholder"):
|
if field_def.get("placeholder"):
|
||||||
inp.placeholder = field_def.get("placeholder")
|
inp.placeholder = field_def.get("placeholder")
|
||||||
container.mount(inp)
|
row.mount(inp)
|
||||||
|
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# If it's a store, we might have required keys (legacy check fallback)
|
# If it's a store, we might have required keys (legacy check fallback)
|
||||||
@@ -398,7 +502,10 @@ class ConfigModal(ModalScreen):
|
|||||||
container.mount(Label(rk))
|
container.mount(Label(rk))
|
||||||
inp_id = f"item-{idx}"
|
inp_id = f"item-{idx}"
|
||||||
self._input_id_map[inp_id] = rk
|
self._input_id_map[inp_id] = rk
|
||||||
container.mount(Input(value="", id=inp_id, classes="config-input"))
|
row = Horizontal(classes="field-row")
|
||||||
|
container.mount(row)
|
||||||
|
row.mount(Input(value="", id=inp_id, classes="config-input"))
|
||||||
|
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# If it's a provider, we might have required keys (legacy check fallback)
|
# If it's a provider, we might have required keys (legacy check fallback)
|
||||||
@@ -414,7 +521,10 @@ class ConfigModal(ModalScreen):
|
|||||||
container.mount(Label(rk))
|
container.mount(Label(rk))
|
||||||
inp_id = f"item-{idx}"
|
inp_id = f"item-{idx}"
|
||||||
self._input_id_map[inp_id] = rk
|
self._input_id_map[inp_id] = rk
|
||||||
container.mount(Input(value="", id=inp_id, classes="config-input"))
|
row = Horizontal(classes="field-row")
|
||||||
|
row.mount(Input(value="", id=inp_id, classes="config-input"))
|
||||||
|
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||||
|
container.mount(row)
|
||||||
idx += 1
|
idx += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -428,6 +538,8 @@ class ConfigModal(ModalScreen):
|
|||||||
if not event.item: return
|
if not event.item: return
|
||||||
if event.item.id == "cat-globals":
|
if event.item.id == "cat-globals":
|
||||||
self.current_category = "globals"
|
self.current_category = "globals"
|
||||||
|
elif event.item.id == "cat-networking":
|
||||||
|
self.current_category = "networking"
|
||||||
elif event.item.id == "cat-stores":
|
elif event.item.id == "cat-stores":
|
||||||
self.current_category = "stores"
|
self.current_category = "stores"
|
||||||
elif event.item.id == "cat-providers":
|
elif event.item.id == "cat-providers":
|
||||||
@@ -451,7 +563,24 @@ class ConfigModal(ModalScreen):
|
|||||||
if not self.validate_current_editor():
|
if not self.validate_current_editor():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self.save_all()
|
# If we are editing networking.zerotier, check if network_id changed and join it
|
||||||
|
if self.editing_item_type == "networking" and self.editing_item_name == "zerotier":
|
||||||
|
old_id = str(self.config_data.get("networking", {}).get("zerotier", {}).get("network_id") or "").strip()
|
||||||
|
self.save_all()
|
||||||
|
new_id = str(self.config_data.get("networking", {}).get("zerotier", {}).get("network_id") or "").strip()
|
||||||
|
|
||||||
|
if new_id and new_id != old_id:
|
||||||
|
from API import zerotier as zt
|
||||||
|
try:
|
||||||
|
if zt.join_network(new_id):
|
||||||
|
self.notify(f"Joined ZeroTier network {new_id}")
|
||||||
|
else:
|
||||||
|
self.notify(f"Config saved, but failed to join network {new_id}", severity="warning")
|
||||||
|
except Exception as exc:
|
||||||
|
self.notify(f"Join error: {exc}", severity="error")
|
||||||
|
else:
|
||||||
|
self.save_all()
|
||||||
|
|
||||||
self.notify("Configuration saved!")
|
self.notify("Configuration saved!")
|
||||||
# Return to the main list view within the current category
|
# Return to the main list view within the current category
|
||||||
self.editing_item_name = None
|
self.editing_item_name = None
|
||||||
@@ -459,6 +588,15 @@ class ConfigModal(ModalScreen):
|
|||||||
self.refresh_view()
|
self.refresh_view()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
|
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
|
||||||
|
elif bid.startswith("zt-leave-"):
|
||||||
|
nid = bid.replace("zt-leave-", "")
|
||||||
|
from API import zerotier as zt
|
||||||
|
try:
|
||||||
|
zt.leave_network(nid)
|
||||||
|
self.notify(f"Left ZeroTier network {nid}")
|
||||||
|
self.refresh_view()
|
||||||
|
except Exception as exc:
|
||||||
|
self.notify(f"Failed to leave: {exc}", severity="error")
|
||||||
elif bid in self._button_id_map:
|
elif bid in self._button_id_map:
|
||||||
action, itype, name = self._button_id_map[bid]
|
action, itype, name = self._button_id_map[bid]
|
||||||
if action == "edit":
|
if action == "edit":
|
||||||
@@ -474,6 +612,9 @@ class ConfigModal(ModalScreen):
|
|||||||
elif itype == "provider":
|
elif itype == "provider":
|
||||||
if "provider" in self.config_data and name in self.config_data["provider"]:
|
if "provider" in self.config_data and name in self.config_data["provider"]:
|
||||||
del self.config_data["provider"][name]
|
del self.config_data["provider"][name]
|
||||||
|
elif itype == "networking":
|
||||||
|
if "networking" in self.config_data and name in self.config_data["networking"]:
|
||||||
|
del self.config_data["networking"][name]
|
||||||
self.refresh_view()
|
self.refresh_view()
|
||||||
elif bid == "add-store-btn":
|
elif bid == "add-store-btn":
|
||||||
all_classes = _discover_store_classes()
|
all_classes = _discover_store_classes()
|
||||||
@@ -499,9 +640,102 @@ class ConfigModal(ModalScreen):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected)
|
self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected)
|
||||||
|
elif bid == "add-net-btn":
|
||||||
|
options = ["zerotier"]
|
||||||
|
self.app.push_screen(SelectionModal("Select Networking Service", options), callback=self.on_net_type_selected)
|
||||||
|
elif bid.startswith("paste-"):
|
||||||
|
# Programmatic paste button
|
||||||
|
target_id = bid.replace("paste-", "")
|
||||||
|
try:
|
||||||
|
inp = self.query_one(f"#{target_id}", Input)
|
||||||
|
self.focus_and_paste(inp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def focus_and_paste(self, inp: Input) -> None:
|
||||||
|
if hasattr(self.app, "paste_from_clipboard"):
|
||||||
|
text = await self.app.paste_from_clipboard()
|
||||||
|
if text:
|
||||||
|
# Replace selection or append
|
||||||
|
inp.value = str(inp.value) + text
|
||||||
|
inp.focus()
|
||||||
|
self.notify("Pasted from clipboard")
|
||||||
|
else:
|
||||||
|
self.notify("Clipboard not supported in this terminal", severity="warning")
|
||||||
|
|
||||||
|
async def action_paste(self) -> None:
|
||||||
|
focused = self.focused
|
||||||
|
if isinstance(focused, Input):
|
||||||
|
await self.focus_and_paste(focused)
|
||||||
|
|
||||||
|
async def action_copy(self) -> None:
|
||||||
|
focused = self.focused
|
||||||
|
if isinstance(focused, Input) and focused.value:
|
||||||
|
if hasattr(self.app, "copy_to_clipboard"):
|
||||||
|
self.app.copy_to_clipboard(str(focused.value))
|
||||||
|
self.notify("Copied to clipboard")
|
||||||
|
else:
|
||||||
|
self.notify("Clipboard not supported in this terminal", severity="warning")
|
||||||
|
|
||||||
def on_store_type_selected(self, stype: str) -> None:
|
def on_store_type_selected(self, stype: str) -> None:
|
||||||
if not stype: return
|
if not stype: return
|
||||||
|
|
||||||
|
if stype == "zerotier":
|
||||||
|
# Push a discovery screen
|
||||||
|
from TUI.modalscreen.selection_modal import SelectionModal
|
||||||
|
from API import zerotier as zt
|
||||||
|
|
||||||
|
# Find all joined networks
|
||||||
|
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()
|
||||||
|
|
||||||
|
self.app.push_screen(SelectionModal("Discovered ZeroTier Services", options), callback=on_peer_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:
|
||||||
self.config_data["store"] = {}
|
self.config_data["store"] = {}
|
||||||
@@ -566,6 +800,18 @@ class ConfigModal(ModalScreen):
|
|||||||
self.editing_item_name = ptype
|
self.editing_item_name = ptype
|
||||||
self.refresh_view()
|
self.refresh_view()
|
||||||
|
|
||||||
|
def on_net_type_selected(self, ntype: str) -> None:
|
||||||
|
if not ntype: return
|
||||||
|
self.editing_item_type = "networking"
|
||||||
|
self.editing_item_name = ntype
|
||||||
|
|
||||||
|
# Ensure it exists in config_data
|
||||||
|
net = self.config_data.setdefault("networking", {})
|
||||||
|
if ntype not in net:
|
||||||
|
net[ntype] = {}
|
||||||
|
|
||||||
|
self.refresh_view()
|
||||||
|
|
||||||
def _update_config_value(self, widget_id: str, value: Any) -> None:
|
def _update_config_value(self, widget_id: str, value: Any) -> None:
|
||||||
if widget_id not in self._input_id_map:
|
if widget_id not in self._input_id_map:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ Prerequisites
|
|||||||
- The Medios-Macina instance on each machine should run the `remote_storage_server.py`
|
- The Medios-Macina instance on each machine should run the `remote_storage_server.py`
|
||||||
or a Hydrus client instance you want to expose.
|
or a Hydrus client instance you want to expose.
|
||||||
- The remote storage server requires Flask and Flask-CORS to run (install with: `pip install flask flask-cors`).
|
- The remote storage server requires Flask and Flask-CORS to run (install with: `pip install flask flask-cors`).
|
||||||
|
|
||||||
|
Auto-install behavior
|
||||||
|
- When a `zerotier` section is present in `config.conf` **or** a `store=zerotier` instance is configured, the CLI will attempt to auto-install the required packages (`flask`, `flask-cors`, and `werkzeug`) on startup unless you disable it with `auto_install = false` in the `zerotier` config block. This mirrors the behavior for other optional features (e.g., Soulseek).
|
||||||
- On your controller/management machine, authorize members via ZeroTier Central.
|
- On your controller/management machine, authorize members via ZeroTier Central.
|
||||||
|
|
||||||
Configuration (conceptual)
|
Configuration (conceptual)
|
||||||
@@ -42,7 +45,7 @@ Add a `store=zerotier` block so the Store registry can create a ZeroTier store i
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
[store=zerotier]
|
[store=zerotier]
|
||||||
my-remote = { "NAME": "my-remote", "NETWORK_ID": "8056c2e21c000001", "SERVICE": "remote", "PORT": 5000, "API_KEY": "myremotekey" }
|
my-remote = { "NAME": "my-remote", "NETWORK_ID": "8056c2e21c000001", "SERVICE": "remote", "PORT": 999, "API_KEY": "myremotekey" }
|
||||||
hydrus-remote = { "NAME": "hydrus-remote", "NETWORK_ID": "8056c2e21c000001", "SERVICE": "hydrus", "PORT": 45869, "API_KEY": "hydrus-access-key" }
|
hydrus-remote = { "NAME": "hydrus-remote", "NETWORK_ID": "8056c2e21c000001", "SERVICE": "hydrus", "PORT": 45869, "API_KEY": "hydrus-access-key" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ python .\scripts\zerotier_setup.py --upload 8056c2e21c000001 --file "C:\path\to\
|
|||||||
Or using curl directly against a discovered ZeroTier peer's IP:
|
Or using curl directly against a discovered ZeroTier peer's IP:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
curl -X POST -H "X-API-Key: myremotekey" -F "file=@/path/to/file.mp4" -F "tag=tag1" http://<zerotier-ip>:5000/files/upload
|
curl -X POST -H "X-API-Key: myremotekey" -F "file=@/path/to/file.mp4" -F "tag=tag1" http://<zerotier-ip>:999/files/upload
|
||||||
```
|
```
|
||||||
|
|
||||||
If you'd like I can:
|
If you'd like I can:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ server and uses it as a remote storage backend through the RemoteStorageBackend.
|
|||||||
$ pip install flask flask-cors
|
$ pip install flask flask-cors
|
||||||
3. Copy this file to your device
|
3. Copy this file to your device
|
||||||
4. Run it (with optional API key):
|
4. Run it (with optional API key):
|
||||||
$ python remote_storage_server.py --storage-path /path/to/storage --port 5000
|
$ python remote_storage_server.py --storage-path /path/to/storage --port 999
|
||||||
$ python remote_storage_server.py --storage-path /path/to/storage --api-key mysecretkey
|
$ python remote_storage_server.py --storage-path /path/to/storage --api-key mysecretkey
|
||||||
5. Server prints connection info automatically (IP, port, API key)
|
5. Server prints connection info automatically (IP, port, API key)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ server and uses it as a remote storage backend through the RemoteStorageBackend.
|
|||||||
2. Add to config.conf:
|
2. Add to config.conf:
|
||||||
[store=remote]
|
[store=remote]
|
||||||
name="phone"
|
name="phone"
|
||||||
url="http://192.168.1.100:5000"
|
url="http://192.168.1.100:999"
|
||||||
api_key="mysecretkey"
|
api_key="mysecretkey"
|
||||||
timeout=30
|
timeout=30
|
||||||
Note: API key is optional. Works on WiFi or cellular data.
|
Note: API key is optional. Works on WiFi or cellular data.
|
||||||
@@ -567,7 +567,7 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Remote Storage Server for Medios-Macina",
|
description="Remote Storage Server for Medios-Macina",
|
||||||
epilog=
|
epilog=
|
||||||
"Example: python remote_storage_server.py --storage-path /storage/media --port 5000 --api-key mysecretkey",
|
"Example: python remote_storage_server.py --storage-path /storage/media --port 999 --api-key mysecretkey",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--storage-path",
|
"--storage-path",
|
||||||
@@ -584,8 +584,8 @@ def main():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--port",
|
"--port",
|
||||||
type=int,
|
type=int,
|
||||||
default=5000,
|
default=999,
|
||||||
help="Server port (default: 5000)"
|
help="Server port (default: 999)"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--api-key",
|
"--api-key",
|
||||||
@@ -628,6 +628,13 @@ def main():
|
|||||||
if args.api_key:
|
if args.api_key:
|
||||||
print(f'api_key="{args.api_key}"')
|
print(f'api_key="{args.api_key}"')
|
||||||
print("timeout=30")
|
print("timeout=30")
|
||||||
|
|
||||||
|
print("\nOR use ZeroTier Networking (Server Side):")
|
||||||
|
print("[networking=zerotier]")
|
||||||
|
print(f'serve="{STORAGE_PATH.name}"')
|
||||||
|
print(f'port="{args.port}"')
|
||||||
|
if args.api_key:
|
||||||
|
print(f'api_key="{args.api_key}"')
|
||||||
print(f"\n{'='*70}\n")
|
print(f"\n{'='*70}\n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -53,14 +53,24 @@ def main(argv=None):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.join:
|
if args.join:
|
||||||
ok = zerotier.join_network(args.join)
|
try:
|
||||||
print("Joined" if ok else "Failed to join")
|
ok = zerotier.join_network(args.join)
|
||||||
return 0 if ok else 2
|
print("Joined" if ok else "Failed to join")
|
||||||
|
return 0 if ok else 2
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Join failed: {exc}")
|
||||||
|
print(f"Join failed: {exc}")
|
||||||
|
return 2
|
||||||
|
|
||||||
if args.leave:
|
if args.leave:
|
||||||
ok = zerotier.leave_network(args.leave)
|
try:
|
||||||
print("Left" if ok else "Failed to leave")
|
ok = zerotier.leave_network(args.leave)
|
||||||
return 0 if ok else 2
|
print("Left" if ok else "Failed to leave")
|
||||||
|
return 0 if ok else 2
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Leave failed: {exc}")
|
||||||
|
print(f"Leave failed: {exc}")
|
||||||
|
return 2
|
||||||
|
|
||||||
if args.discover:
|
if args.discover:
|
||||||
probes = zerotier.discover_services_on_network(args.discover)
|
probes = zerotier.discover_services_on_network(args.discover)
|
||||||
|
|||||||
Reference in New Issue
Block a user