f
This commit is contained in:
646
API/zerotier.py
646
API/zerotier.py
@@ -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
|
||||
Reference in New Issue
Block a user