#!/usr/bin/env python3 """Read-only laboratory probe for ha-mcp bare-root administrative routes. Safety properties: - GET only - no redirects - no response body output - refuses public IP addresses - requires explicit ownership acknowledgement """ from __future__ import annotations import argparse import ipaddress import socket import sys import urllib.error import urllib.parse import urllib.request PATHS = ( "/api/settings/info", "/api/settings/tools", "/api/policy/config", ) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None def resolve_private_host(hostname: str) -> list[str]: try: addresses = sorted({item[4][0] for item in socket.getaddrinfo(hostname, None)}) except socket.gaierror as exc: raise ValueError(f"Unable to resolve target host: {exc}") from exc if not addresses: raise ValueError("Target host resolved to no addresses") for address in addresses: ip = ipaddress.ip_address(address) allowed = ip.is_loopback or ip.is_private or ip.is_link_local if not allowed: raise ValueError( f"Refusing non-lab address {address}. " "Use this probe only with loopback, private, or link-local targets." ) return addresses def probe(base_url: str, timeout: float) -> int: parsed = urllib.parse.urlparse(base_url) if parsed.scheme not in {"http", "https"}: print("ERROR: base URL must use http or https", file=sys.stderr) return 2 if not parsed.hostname: print("ERROR: base URL must include a hostname", file=sys.stderr) return 2 addresses = resolve_private_host(parsed.hostname) print(f"Target host resolves to lab address(es): {', '.join(addresses)}") print("Only HTTP status and response length are reported. Bodies are discarded.") opener = urllib.request.build_opener(NoRedirect) failures = 0 for path in PATHS: url = urllib.parse.urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) request = urllib.request.Request( url, method="GET", headers={ "User-Agent": "ha-mcp-safe-lab-probe/1.0", "Accept": "application/json", }, ) try: with opener.open(request, timeout=timeout) as response: length = response.headers.get("Content-Length", "unknown") print(f"{path}: HTTP {response.status}, content-length={length}") except urllib.error.HTTPError as exc: length = exc.headers.get("Content-Length", "unknown") if exc.headers else "unknown" print(f"{path}: HTTP {exc.code}, content-length={length}") except Exception as exc: failures += 1 print(f"{path}: ERROR {type(exc).__name__}: {exc}") return 1 if failures else 0 def main() -> int: parser = argparse.ArgumentParser(description="Read-only ha-mcp laboratory probe") parser.add_argument("--base-url", required=True, help="Lab URL, e.g. http://192.168.56.10:9583") parser.add_argument("--timeout", type=float, default=5.0) parser.add_argument( "--i-own-this-system", action="store_true", help="Confirm that you own or are authorized to test the target", ) args = parser.parse_args() if not args.i_own_this_system: print( "ERROR: explicit authorization acknowledgement is required. " "Re-run with --i-own-this-system only for a system you own or administer.", file=sys.stderr, ) return 2 try: return probe(args.base_url, args.timeout) except ValueError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())