f
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -236,4 +236,7 @@ scripts/mm.ps1
|
||||
scripts/mm
|
||||
.style.yapf
|
||||
.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():
|
||||
nets = zerotier.list_networks()
|
||||
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
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -64,8 +65,172 @@ class ZeroTierServiceProbe:
|
||||
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:
|
||||
return bool(shutil.which("zerotier-cli"))
|
||||
return _get_cli_path() is not None
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
@@ -73,23 +238,49 @@ def is_available() -> bool:
|
||||
return _HAVE_PY_ZEROTIER or _cli_available()
|
||||
|
||||
|
||||
def _run_cli_json(*args: str, timeout: float = 5.0) -> Any:
|
||||
"""Run zerotier-cli with arguments and parse JSON output if possible.
|
||||
def _run_cli_capture(*args: str, timeout: float = 5.0) -> Tuple[int, str, str]:
|
||||
"""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:
|
||||
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}")
|
||||
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:
|
||||
return json.loads(out.decode("utf-8"))
|
||||
return json.loads(out)
|
||||
except Exception:
|
||||
# Some CLI invocations might print non-json; return as raw string
|
||||
return out.decode("utf-8", "replace")
|
||||
return out
|
||||
|
||||
|
||||
def list_networks() -> List[ZeroTierNetwork]:
|
||||
@@ -103,14 +294,21 @@ def list_networks() -> List[ZeroTierNetwork]:
|
||||
try:
|
||||
# Attempt to use common API shape (best-effort)
|
||||
raw = _zt.list_networks() # type: ignore[attr-defined]
|
||||
for n in raw or []:
|
||||
nets.append(ZeroTierNetwork(
|
||||
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
|
||||
# If the Python binding returned results, use them. If it returned
|
||||
# an empty list/None, fall back to the CLI so we don't return a
|
||||
# false-empty result to the UI.
|
||||
if raw:
|
||||
for n in raw:
|
||||
# raw entries are expected to be dict-like
|
||||
nets.append(ZeroTierNetwork(
|
||||
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
|
||||
debug(f"py-zerotier listing failed: {exc}")
|
||||
|
||||
@@ -149,10 +347,15 @@ def join_network(network_id: str) -> bool:
|
||||
|
||||
if _cli_available():
|
||||
try:
|
||||
subprocess.check_call([shutil.which("zerotier-cli"), "join", network_id], timeout=10)
|
||||
return True
|
||||
except Exception as exc:
|
||||
debug(f"zerotier-cli join failed: {exc}")
|
||||
rc, out, err = _run_cli_capture("join", network_id, timeout=10)
|
||||
if rc == 0:
|
||||
return True
|
||||
# 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
|
||||
|
||||
|
||||
@@ -170,10 +373,12 @@ def leave_network(network_id: str) -> bool:
|
||||
|
||||
if _cli_available():
|
||||
try:
|
||||
subprocess.check_call([shutil.which("zerotier-cli"), "leave", network_id], timeout=10)
|
||||
return True
|
||||
except Exception as exc:
|
||||
debug(f"zerotier-cli leave failed: {exc}")
|
||||
rc, out, err = _run_cli_capture("leave", network_id, timeout=10)
|
||||
if rc == 0:
|
||||
return True
|
||||
raise RuntimeError(f"zerotier-cli leave failed (rc={rc}): {err.strip() or out.strip()}")
|
||||
except Exception:
|
||||
raise
|
||||
return False
|
||||
|
||||
|
||||
@@ -199,6 +404,32 @@ def get_assigned_addresses(network_id: str) -> List[str]:
|
||||
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]]:
|
||||
"""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,
|
||||
timeout: float = 2.0,
|
||||
accept_json: bool = True,
|
||||
api_token: Optional[str] = None,
|
||||
) -> 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.
|
||||
|
||||
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.
|
||||
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).
|
||||
"""
|
||||
net = str(network_id or "").strip()
|
||||
if not net:
|
||||
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"])
|
||||
|
||||
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] = []
|
||||
|
||||
for addr in addresses:
|
||||
@@ -327,15 +566,18 @@ def find_peer_service(
|
||||
port: Optional[int] = None,
|
||||
path_candidates: Optional[List[str]] = None,
|
||||
timeout: float = 2.0,
|
||||
api_token: Optional[str] = None,
|
||||
) -> Optional[ZeroTierServiceProbe]:
|
||||
"""Return the first probe that matches service_hint or is successful.
|
||||
|
||||
Useful for selecting a peer to configure a store against.
|
||||
"""
|
||||
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:
|
||||
return None
|
||||
if service_hint:
|
||||
|
||||
110
CLI.py
110
CLI.py
@@ -4415,6 +4415,116 @@ class MedeiaCLI:
|
||||
def repl() -> None:
|
||||
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)
|
||||
def main_callback(ctx: typer.Context) -> None:
|
||||
if ctx.invoked_subcommand is None:
|
||||
|
||||
@@ -187,6 +187,21 @@ def _apply_conf_block(
|
||||
tool[tool_name] = dict(block)
|
||||
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]:
|
||||
"""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
|
||||
for key in sorted(config.keys()):
|
||||
if key in {"store", "provider", "tool"}:
|
||||
if key in {"store", "provider", "tool", "networking"}:
|
||||
continue
|
||||
value = config.get(key)
|
||||
if isinstance(value, dict):
|
||||
@@ -351,6 +366,24 @@ def _serialize_conf(config: Dict[str, Any]) -> str:
|
||||
seen_keys.add(k_upper)
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,13 @@ _PROVIDER_DEPENDENCIES: Dict[str, List[Tuple[str, str]]] = {
|
||||
"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]:
|
||||
return [
|
||||
@@ -144,5 +151,29 @@ def maybe_auto_install_configured_tools(config: Dict[str, Any]) -> None:
|
||||
label = f"{provider_name.title()} provider"
|
||||
_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"]
|
||||
|
||||
@@ -8,7 +8,7 @@ Configuration keys:
|
||||
- NAME: store instance name (required)
|
||||
- NETWORK_ID: ZeroTier network ID to use for discovery (required)
|
||||
- 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
|
||||
- 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": "NETWORK_ID", "label": "ZeroTier Network ID", "default": "", "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": "HOST", "label": "Preferred peer host (optional)", "default": "", "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._network_id = str(network_id or "").strip()
|
||||
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._preferred_host = str(host or "").strip() or None
|
||||
self._timeout = int(timeout or 5)
|
||||
@@ -123,8 +123,20 @@ class ZeroTier(Store):
|
||||
debug(f"ZeroTier discovery helper not available: {exc}")
|
||||
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
|
||||
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:
|
||||
# Extract host:port
|
||||
host = probe.address
|
||||
|
||||
@@ -17,6 +17,11 @@ from TUI.modalscreen.selection_modal import SelectionModal
|
||||
class ConfigModal(ModalScreen):
|
||||
"""A modal for editing the configuration."""
|
||||
|
||||
BINDINGS = [
|
||||
("ctrl+v", "paste", "Paste"),
|
||||
("ctrl+c", "copy", "Copy"),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
ConfigModal {
|
||||
align: center middle;
|
||||
@@ -63,8 +68,19 @@ class ConfigModal(ModalScreen):
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
height: 5;
|
||||
margin-bottom: 1;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.config-input {
|
||||
width: 100%;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
.paste-btn {
|
||||
width: 10;
|
||||
margin-left: 1;
|
||||
}
|
||||
|
||||
#config-actions {
|
||||
@@ -115,6 +131,7 @@ class ConfigModal(ModalScreen):
|
||||
yield Label("Categories", classes="config-label")
|
||||
with ListView(id="category-list"):
|
||||
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("Providers"), id="cat-providers")
|
||||
|
||||
@@ -124,13 +141,14 @@ class ConfigModal(ModalScreen):
|
||||
yield Button("Save", variant="success", id="save-btn")
|
||||
yield Button("Add Store", variant="primary", id="add-store-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("Close", variant="error", id="cancel-btn")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#add-store-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()
|
||||
|
||||
def refresh_view(self) -> None:
|
||||
@@ -146,6 +164,7 @@ class ConfigModal(ModalScreen):
|
||||
try:
|
||||
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-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("#save-btn", Button).display = (self.editing_item_name is not None or self.current_category == "globals")
|
||||
except Exception:
|
||||
@@ -158,6 +177,8 @@ class ConfigModal(ModalScreen):
|
||||
self.render_item_editor(container)
|
||||
elif self.current_category == "globals":
|
||||
self.render_globals(container)
|
||||
elif self.current_category == "networking":
|
||||
self.render_networking(container)
|
||||
elif self.current_category == "stores":
|
||||
self.render_stores(container)
|
||||
elif self.current_category == "providers":
|
||||
@@ -202,7 +223,10 @@ class ConfigModal(ModalScreen):
|
||||
sel = Select(select_options, value=current_val, id=inp_id)
|
||||
container.mount(sel)
|
||||
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
|
||||
|
||||
# Show any other top-level keys not in schema
|
||||
@@ -214,9 +238,66 @@ class ConfigModal(ModalScreen):
|
||||
inp_id = f"global-{idx}"
|
||||
self._input_id_map[inp_id] = 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
|
||||
|
||||
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:
|
||||
container.mount(Label("Configured Stores", classes="config-label"))
|
||||
stores = self.config_data.get("store", {})
|
||||
@@ -310,6 +391,23 @@ class ConfigModal(ModalScreen):
|
||||
provider_schema_map[k.upper()] = field_def
|
||||
except Exception:
|
||||
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
|
||||
existing_keys_upper = set()
|
||||
@@ -351,10 +449,13 @@ class ConfigModal(ModalScreen):
|
||||
sel = Select(select_options, value=current_val, id=inp_id)
|
||||
container.mount(sel)
|
||||
else:
|
||||
row = Horizontal(classes="field-row")
|
||||
container.mount(row)
|
||||
inp = Input(value=str(v), id=inp_id, classes="config-input")
|
||||
if is_secret:
|
||||
inp.password = True
|
||||
container.mount(inp)
|
||||
row.mount(inp)
|
||||
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||
idx += 1
|
||||
|
||||
# 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)
|
||||
container.mount(sel)
|
||||
else:
|
||||
row = Horizontal(classes="field-row")
|
||||
container.mount(row)
|
||||
inp = Input(value=default_val, id=inp_id, classes="config-input")
|
||||
if field_def.get("secret"):
|
||||
inp.password = True
|
||||
if 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
|
||||
|
||||
# If it's a store, we might have required keys (legacy check fallback)
|
||||
@@ -398,7 +502,10 @@ class ConfigModal(ModalScreen):
|
||||
container.mount(Label(rk))
|
||||
inp_id = f"item-{idx}"
|
||||
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
|
||||
|
||||
# If it's a provider, we might have required keys (legacy check fallback)
|
||||
@@ -414,7 +521,10 @@ class ConfigModal(ModalScreen):
|
||||
container.mount(Label(rk))
|
||||
inp_id = f"item-{idx}"
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
@@ -428,6 +538,8 @@ class ConfigModal(ModalScreen):
|
||||
if not event.item: return
|
||||
if event.item.id == "cat-globals":
|
||||
self.current_category = "globals"
|
||||
elif event.item.id == "cat-networking":
|
||||
self.current_category = "networking"
|
||||
elif event.item.id == "cat-stores":
|
||||
self.current_category = "stores"
|
||||
elif event.item.id == "cat-providers":
|
||||
@@ -451,7 +563,24 @@ class ConfigModal(ModalScreen):
|
||||
if not self.validate_current_editor():
|
||||
return
|
||||
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!")
|
||||
# Return to the main list view within the current category
|
||||
self.editing_item_name = None
|
||||
@@ -459,6 +588,15 @@ class ConfigModal(ModalScreen):
|
||||
self.refresh_view()
|
||||
except Exception as exc:
|
||||
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:
|
||||
action, itype, name = self._button_id_map[bid]
|
||||
if action == "edit":
|
||||
@@ -474,6 +612,9 @@ class ConfigModal(ModalScreen):
|
||||
elif itype == "provider":
|
||||
if "provider" in self.config_data and name in self.config_data["provider"]:
|
||||
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()
|
||||
elif bid == "add-store-btn":
|
||||
all_classes = _discover_store_classes()
|
||||
@@ -499,9 +640,102 @@ class ConfigModal(ModalScreen):
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
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}"
|
||||
if "store" not in self.config_data:
|
||||
self.config_data["store"] = {}
|
||||
@@ -566,6 +800,18 @@ class ConfigModal(ModalScreen):
|
||||
self.editing_item_name = ptype
|
||||
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:
|
||||
if widget_id not in self._input_id_map:
|
||||
return
|
||||
|
||||
@@ -14,6 +14,9 @@ Prerequisites
|
||||
- The Medios-Macina instance on each machine should run the `remote_storage_server.py`
|
||||
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`).
|
||||
|
||||
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.
|
||||
|
||||
Configuration (conceptual)
|
||||
@@ -42,7 +45,7 @@ Add a `store=zerotier` block so the Store registry can create a ZeroTier store i
|
||||
|
||||
```ini
|
||||
[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" }
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
```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:
|
||||
|
||||
@@ -13,7 +13,7 @@ server and uses it as a remote storage backend through the RemoteStorageBackend.
|
||||
$ pip install flask flask-cors
|
||||
3. Copy this file to your device
|
||||
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
|
||||
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:
|
||||
[store=remote]
|
||||
name="phone"
|
||||
url="http://192.168.1.100:5000"
|
||||
url="http://192.168.1.100:999"
|
||||
api_key="mysecretkey"
|
||||
timeout=30
|
||||
Note: API key is optional. Works on WiFi or cellular data.
|
||||
@@ -567,7 +567,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Remote Storage Server for Medios-Macina",
|
||||
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(
|
||||
"--storage-path",
|
||||
@@ -584,8 +584,8 @@ def main():
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=5000,
|
||||
help="Server port (default: 5000)"
|
||||
default=999,
|
||||
help="Server port (default: 999)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
@@ -628,6 +628,13 @@ def main():
|
||||
if args.api_key:
|
||||
print(f'api_key="{args.api_key}"')
|
||||
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")
|
||||
|
||||
try:
|
||||
|
||||
@@ -53,14 +53,24 @@ def main(argv=None):
|
||||
return 0
|
||||
|
||||
if args.join:
|
||||
ok = zerotier.join_network(args.join)
|
||||
print("Joined" if ok else "Failed to join")
|
||||
return 0 if ok else 2
|
||||
try:
|
||||
ok = zerotier.join_network(args.join)
|
||||
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:
|
||||
ok = zerotier.leave_network(args.leave)
|
||||
print("Left" if ok else "Failed to leave")
|
||||
return 0 if ok else 2
|
||||
try:
|
||||
ok = zerotier.leave_network(args.leave)
|
||||
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:
|
||||
probes = zerotier.discover_services_on_network(args.discover)
|
||||
|
||||
Reference in New Issue
Block a user