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://:8001" )