This commit is contained in:
2026-01-22 02:45:08 -08:00
parent 3d571b007b
commit ba23c0606f
18 changed files with 75 additions and 5355 deletions

View File

@@ -1,646 +0,0 @@
"""ZeroTier helpers and discovery utilities.
This module provides a small, dependency-light API for interacting with a
local zerotier-one node (preferred via Python module when available, else via
`zerotier-cli`), discovering peers on a given ZeroTier network, and probing
for services running on those peers (e.g., our remote storage server or a
Hydrus instance).
Notes:
- This is intentionally conservative and all operations are best-effort and
fail gracefully when the local system does not have ZeroTier installed.
- The implementation prefers a Python ZeroTier binding when available, else
falls back to calling the `zerotier-cli` binary (if present) and parsing
JSON output where possible.
Example usage:
from API import zerotier
if zerotier.is_available():
nets = zerotier.list_networks()
zerotier.join_network("8056c2e21c000001")
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
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from SYS.logger import debug
# Optional Python ZeroTier bindings - prefer them when available
_HAVE_PY_ZEROTIER = False
try:
# Try common package names; not all installations will have this available
# This import is optional and callers should still work via the CLI fallback.
import zerotier as _zt # type: ignore
_HAVE_PY_ZEROTIER = True
except Exception:
_HAVE_PY_ZEROTIER = False
@dataclass
class ZeroTierNetwork:
id: str
name: str
status: str
assigned_addresses: List[str]
@dataclass
class ZeroTierServiceProbe:
address: str
port: int
path: str
url: str
ok: bool
status_code: Optional[int]
payload: Optional[Any]
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 _get_cli_path() is not None
def is_available() -> bool:
"""Return True if we can interact with ZeroTier locally (module or CLI)."""
return _HAVE_PY_ZEROTIER or _cli_available()
def _run_cli_capture(*args: str, timeout: float = 5.0) -> Tuple[int, str, str]:
"""Run zerotier-cli and return (returncode, stdout, stderr).
This centralizes how we call the CLI so we can always capture stderr and
returncodes and make debugging failures much easier.
"""
bin_path = _get_cli_path()
if not bin_path:
raise RuntimeError("zerotier-cli not found")
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}")
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)
except Exception:
# Some CLI invocations might print non-json; return as raw string
return out
def list_networks() -> List[ZeroTierNetwork]:
"""Return a list of configured ZeroTier networks on this node.
Best-effort: prefers Python binding, then `zerotier-cli listnetworks -j`.
"""
nets: List[ZeroTierNetwork] = []
if _HAVE_PY_ZEROTIER:
try:
# Attempt to use common API shape (best-effort)
raw = _zt.list_networks() # type: ignore[attr-defined]
# 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}")
# CLI fallback
try:
data = _run_cli_json("listnetworks", "-j")
if isinstance(data, list):
for entry in data:
nets.append(ZeroTierNetwork(
id=str(entry.get("id") or ""),
name=str(entry.get("name") or ""),
status=str(entry.get("status") or ""),
assigned_addresses=list(entry.get("assignedAddresses") or []),
))
except Exception as exc:
debug(f"list_networks failed: {exc}")
return nets
def join_network(network_id: str) -> bool:
"""Join the given ZeroTier network (best-effort).
Returns True on success, False otherwise.
"""
network_id = str(network_id or "").strip()
if not network_id:
raise ValueError("network_id is required")
if _HAVE_PY_ZEROTIER:
try:
_zt.join_network(network_id) # type: ignore[attr-defined]
return True
except Exception as exc: # pragma: no cover - optional dependency
debug(f"py-zerotier join failed: {exc}")
if _cli_available():
try:
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
def leave_network(network_id: str) -> bool:
network_id = str(network_id or "").strip()
if not network_id:
raise ValueError("network_id is required")
if _HAVE_PY_ZEROTIER:
try:
_zt.leave_network(network_id) # type: ignore[attr-defined]
return True
except Exception as exc: # pragma: no cover - optional dependency
debug(f"py-zerotier leave failed: {exc}")
if _cli_available():
try:
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
def _strip_addr(addr: str) -> str:
# Remove trailing CID parts like '/24' and zone IDs like '%eth0'
if not addr:
return addr
a = addr.split("/")[0]
if "%" in a:
a = a.split("%", 1)[0]
return a
def get_assigned_addresses(network_id: str) -> List[str]:
"""Return assigned ZeroTier addresses for the local node on the given network."""
network_id = str(network_id or "").strip()
if not network_id:
return []
for n in list_networks():
if n.id == network_id:
return [str(_strip_addr(a)) for a in n.assigned_addresses if a]
return []
def get_assigned_subnets(network_id: str) -> List[str]:
"""Return CIDR subnets (e.g. '10.147.17.0/24') for the given network."""
network_id = str(network_id or "").strip()
if not network_id:
return []
subnets = []
for n in list_networks():
if n.id == network_id:
for addr in n.assigned_addresses:
if addr and "/" in addr:
# Calculate subnet base
try:
import ipaddress
net = ipaddress.ip_network(addr, strict=False)
subnets.append(str(net))
except Exception:
pass
return subnets
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).
If CLI supports JSON output for peers it will be parsed, otherwise we return
an empty list.
"""
if _HAVE_PY_ZEROTIER:
try:
peers = _zt.list_peers() # type: ignore[attr-defined]
return list(peers or [])
except Exception as exc: # pragma: no cover - optional dependency
debug(f"py-zerotier list_peers failed: {exc}")
if _cli_available():
try:
data = _run_cli_json("peers", "-j")
if isinstance(data, list):
return data
except Exception as exc:
debug(f"zerotier-cli peers failed: {exc}")
return []
def _probe_url(url: str, *, timeout: float = 2.0, accept_json: bool = True) -> Tuple[bool, Optional[int], Optional[Any]]:
"""Try fetching the URL and return (ok, status_code, payload).
Uses httpx if available, otherwise falls back to requests.
"""
try:
try:
import httpx
resp = httpx.get(url, timeout=timeout)
code = int(resp.status_code if hasattr(resp, "status_code") else resp.status)
content_type = str(resp.headers.get("content-type") or "").lower()
if code == 200 and accept_json and "json" in content_type:
try:
return True, code, resp.json()
except Exception:
return True, code, resp.text
return (code == 200), code, resp.text
except Exception:
import requests # type: ignore
resp = requests.get(url, timeout=timeout)
code = int(resp.status_code)
content_type = str(resp.headers.get("content-type") or "").lower()
if code == 200 and accept_json and "json" in content_type:
try:
return True, code, resp.json()
except Exception:
return True, code, resp.text
return (code == 200), code, resp.text
except Exception as exc:
debug(f"Probe failed: {url} -> {exc}")
return False, None, None
def discover_services_on_network(
network_id: str,
*,
ports: Optional[List[int]] = None,
paths: Optional[List[str]] = None,
timeout: float = 2.0,
accept_json: bool = True,
api_token: Optional[str] = None,
) -> List[ZeroTierServiceProbe]:
"""Probe peers on the given network for HTTP services.
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 [999])
paths = list(paths or ["/health", "/api_version"])
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"]:
addr = str(ip).split("/")[0]
if addr not in addresses:
addresses.append(addr)
else:
# Fallback: if no Central token, and we are on a likely /24 subnet,
# we can try to guess/probe peers on that same subnet.
subnets = get_assigned_subnets(net)
for subnet_str in subnets:
try:
import ipaddress
subnet = ipaddress.ip_network(subnet_str, strict=False)
# Only scan if subnet is reasonably small (e.g. <= /24 = 256 hosts)
if subnet.num_addresses <= 256:
for ip in subnet.hosts():
addr = str(ip)
if addr not in addresses:
addresses.append(addr)
except Exception:
pass
probes: List[ZeroTierServiceProbe] = []
# Parallelize probes to make subnet scanning feasible
import concurrent.futures
def do_probe(host):
host_probes = []
for port in ports:
# Try HTTP first as it's the common case for local storage
for scheme in ("http", "https"):
# Fast probe of just the first path
path = paths[0]
url = f"{scheme}://{host}:{port}{path}"
ok, code, payload = _probe_url(url, timeout=timeout, accept_json=accept_json)
if ok or code == 401:
hint = None
try:
# remote_storage_server returns {"status": "ok", ...}
if code == 401:
hint = "remote_storage" # Most likely
elif isinstance(payload, dict) and payload.get("status"):
hint = "remote_storage"
# hydrus returns {"api_version": ...}
elif isinstance(payload, dict) and payload.get("api_version"):
hint = "hydrus"
except Exception:
pass
host_probes.append(ZeroTierServiceProbe(
address=host,
port=int(port),
path=path,
url=url,
ok=(code == 200),
status_code=code,
payload=payload,
service_hint=hint,
))
# Stop probing other schemes/paths for this host/port
break
return host_probes
# Use ThreadPoolExecutor for concurrent I/O probes
max_workers = min(50, len(addresses) or 1)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_addr = {executor.submit(do_probe, addr): addr for addr in addresses}
for future in concurrent.futures.as_completed(future_to_addr):
try:
probes.extend(future.result())
except Exception:
pass
return probes
def find_peer_service(
network_id: str,
*,
service_hint: Optional[str] = None,
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 [999, 5000, 45869, 80, 443]
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:
for p in probes:
if p.service_hint and service_hint.lower() in str(p.service_hint).lower():
return p
# Hydrus detection: check payload for 'api_version'
try:
if service_hint.lower() == "hydrus" and isinstance(p.payload, dict) and p.payload.get("api_version"):
return p
except Exception:
pass
# Fallback: return the first OK probe
return probes[0] if probes else None