137 lines
4.2 KiB
Python
137 lines
4.2 KiB
Python
from __future__ import annotations
|
|
|
|
import concurrent.futures
|
|
import ipaddress
|
|
import json
|
|
import socket
|
|
from urllib.error import URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
DISCOVERY_PATH = "/api/v1/discovery"
|
|
AUTO_VALUES = {"", "auto", "discover", "lan"}
|
|
|
|
|
|
def _is_private_ipv4(value: str) -> bool:
|
|
try:
|
|
ip = ipaddress.ip_address(value)
|
|
except ValueError:
|
|
return False
|
|
return ip.version == 4 and ip.is_private and not ip.is_loopback
|
|
|
|
|
|
def _local_private_ipv4s() -> list[str]:
|
|
addresses: set[str] = set()
|
|
try:
|
|
for value in socket.gethostbyname_ex(socket.gethostname())[2]:
|
|
if _is_private_ipv4(value):
|
|
addresses.add(value)
|
|
except OSError:
|
|
pass
|
|
|
|
# UDP connect does not send packets; it reveals the chosen outbound address.
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
sock.connect(("8.8.8.8", 80))
|
|
value = sock.getsockname()[0]
|
|
if _is_private_ipv4(value):
|
|
addresses.add(value)
|
|
except OSError:
|
|
pass
|
|
|
|
return sorted(addresses)
|
|
|
|
|
|
def _candidate_hosts() -> list[str]:
|
|
hosts: set[str] = {"127.0.0.1"}
|
|
for address in _local_private_ipv4s():
|
|
hosts.add(address)
|
|
network = ipaddress.ip_network(f"{address}/24", strict=False)
|
|
for host in network.hosts():
|
|
hosts.add(str(host))
|
|
return sorted(hosts, key=lambda value: tuple(int(part) for part in value.split(".")))
|
|
|
|
|
|
def _probe(base_url: str, timeout_seconds: float) -> str | None:
|
|
url = f"{base_url.rstrip('/')}{DISCOVERY_PATH}"
|
|
request = Request(url, headers={"Accept": "application/json"})
|
|
try:
|
|
with urlopen(request, timeout=timeout_seconds) as response:
|
|
body = response.read()
|
|
except (OSError, URLError):
|
|
return None
|
|
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
if payload.get("service") != "Agent-S-Controller":
|
|
return None
|
|
discovered = str(payload.get("base_url") or base_url).rstrip("/")
|
|
return discovered or None
|
|
|
|
|
|
def discover_controller_base_url(
|
|
ports: list[int],
|
|
timeout_seconds: float,
|
|
workers: int = 96,
|
|
log=print,
|
|
) -> str | None:
|
|
hosts = _candidate_hosts()
|
|
attempts = [(host, port) for port in ports for host in hosts]
|
|
if not attempts:
|
|
return None
|
|
|
|
log(
|
|
"[agent] discovering controller on "
|
|
+ f"{len(hosts)} hosts x ports {', '.join(str(p) for p in ports)} ..."
|
|
)
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=min(workers, len(attempts))) as executor:
|
|
future_map = {
|
|
executor.submit(_probe, f"http://{host}:{port}", timeout_seconds): (host, port)
|
|
for host, port in attempts
|
|
}
|
|
for future in concurrent.futures.as_completed(future_map):
|
|
found = future.result()
|
|
if found:
|
|
host, port = future_map[future]
|
|
log(f"[agent] discovered controller at {found} ({host}:{port})")
|
|
for pending in future_map:
|
|
pending.cancel()
|
|
return found
|
|
return None
|
|
|
|
|
|
def resolve_controller_base_url(
|
|
configured_base_url: str,
|
|
discovery_enabled: bool,
|
|
discovery_ports: list[int],
|
|
timeout_seconds: float,
|
|
log=print,
|
|
) -> str:
|
|
configured = (configured_base_url or "auto").strip().rstrip("/")
|
|
if configured.lower() not in AUTO_VALUES:
|
|
found = _probe(configured, timeout_seconds)
|
|
if found:
|
|
return found
|
|
if not discovery_enabled:
|
|
return configured
|
|
log(f"[agent] configured controller is not reachable yet: {configured}")
|
|
|
|
if discovery_enabled:
|
|
found = discover_controller_base_url(
|
|
ports=discovery_ports,
|
|
timeout_seconds=timeout_seconds,
|
|
log=log,
|
|
)
|
|
if found:
|
|
return found
|
|
|
|
ports = ", ".join(str(port) for port in discovery_ports)
|
|
raise RuntimeError(
|
|
"Controller was not found. Start the controller first, allow TCP "
|
|
f"{ports} through the firewall, or start the agent with "
|
|
"start_agent.bat http://<controller-lan-ip>:8001"
|
|
)
|