diff --git a/.gitignore b/.gitignore index 21ca1ce..9ba7870 100644 --- a/.gitignore +++ b/.gitignore @@ -236,4 +236,7 @@ scripts/mm.ps1 scripts/mm .style.yapf .yapfignore -tmp_* \ No newline at end of file +tmp_* +*.secret +# Ignore local ZeroTier auth tokens (project-local copy) +authtoken.secret \ No newline at end of file diff --git a/API/zerotier.py b/API/zerotier.py index 283099d..3686455 100644 --- a/API/zerotier.py +++ b/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: diff --git a/CLI.py b/CLI.py index 125b20e..9530a50 100644 --- a/CLI.py +++ b/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: diff --git a/SYS/config.py b/SYS/config.py index 515600e..5bb02af 100644 --- a/SYS/config.py +++ b/SYS/config.py @@ -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" diff --git a/SYS/optional_deps.py b/SYS/optional_deps.py index 1ad0aa0..e34142e 100644 --- a/SYS/optional_deps.py +++ b/SYS/optional_deps.py @@ -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"] diff --git a/Store/ZeroTier.py b/Store/ZeroTier.py index d9bcdcc..e29a05f 100644 --- a/Store/ZeroTier.py +++ b/Store/ZeroTier.py @@ -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 diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index 8a58208..2b971cc 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -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 diff --git a/docs/zerotier.md b/docs/zerotier.md index bc65ccc..8a2d22c 100644 --- a/docs/zerotier.md +++ b/docs/zerotier.md @@ -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://:5000/files/upload +curl -X POST -H "X-API-Key: myremotekey" -F "file=@/path/to/file.mp4" -F "tag=tag1" http://:999/files/upload ``` If you'd like I can: diff --git a/scripts/remote_storage_server.py b/scripts/remote_storage_server.py index ddac81f..6c82f29 100644 --- a/scripts/remote_storage_server.py +++ b/scripts/remote_storage_server.py @@ -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: diff --git a/scripts/zerotier_setup.py b/scripts/zerotier_setup.py index ebe4924..b2b840e 100644 --- a/scripts/zerotier_setup.py +++ b/scripts/zerotier_setup.py @@ -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)