This commit is contained in:
2026-01-14 01:33:25 -08:00
parent 226367a6ea
commit e27e13b64c
10 changed files with 760 additions and 63 deletions

5
.gitignore vendored
View File

@@ -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

View File

@@ -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
View File

@@ -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:

View File

@@ -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"

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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)