"""Lookup RDAP de un dominio, IP o ASN via el CLI `rdap` (openrdap). Funcion IMPURA: ejecuta el binario `rdap` (openrdap, normalmente en ``~/go/bin/rdap``) con ``--json``, captura el JSON crudo y lo parsea. RDAP es el reemplazo moderno de WHOIS sobre HTTP/JSON. Es OSINT pasivo: no toca al objetivo, solo el directorio RDAP publico. Devuelve siempre un dict (estilo del grupo recon): nunca lanza excepciones. """ import json import os import shutil import subprocess def _resolve_rdap_bin() -> str | None: """Localiza el binario rdap: PATH primero, luego ~/go/bin/rdap.""" found = shutil.which("rdap") if found: return found fallback = os.path.expanduser("~/go/bin/rdap") if os.path.isfile(fallback) and os.access(fallback, os.X_OK): return fallback return None def rdap_lookup(target: str, timeout_s: int = 30) -> dict: """Ejecuta `rdap --json ` y parsea la respuesta RDAP. Funcion IMPURA: lanza el CLI `rdap` como subproceso. Captura el JSON crudo (siempre presente en ``raw``) y lo parsea a dict. Devuelve un dict; nunca lanza: los errores se reportan como ``{"status": "error", "error": "..."}``. Args: target: Dominio (ej. ``"google.com"``), direccion IP, o ASN con prefijo ``AS`` (ej. ``"AS15169"``). timeout_s: Segundos maximo de espera del subproceso (default 30). Returns: Dict de exito:: { "status": "ok", "target": , "raw": , "data": , "handle": , "ldhName": , "warning": , # solo si el JSON no parseo } En fallo de ejecucion:: {"status": "error", "error": "", "target": } """ if not target or not target.strip(): return {"status": "error", "error": "rdap_lookup: target vacio", "target": target} target = target.strip() rdap_bin = _resolve_rdap_bin() if not rdap_bin: return { "status": "error", "error": ( "rdap_lookup: binario 'rdap' no encontrado en PATH ni en " "~/go/bin/rdap (instala openrdap: `go install github.com/openrdap/rdap/cmd/rdap@latest`)" ), "target": target, } try: proc = subprocess.run( [rdap_bin, "--json", target], capture_output=True, text=True, timeout=timeout_s, ) except subprocess.TimeoutExpired: return { "status": "error", "error": f"rdap_lookup: timeout tras {timeout_s}s consultando '{target}'", "target": target, } except OSError as e: # pragma: no cover - errores de SO raros return {"status": "error", "error": f"rdap_lookup: {e}", "target": target} raw = proc.stdout or "" if not raw.strip(): err = (proc.stderr or "").strip() or f"rdap devolvio salida vacia (rc={proc.returncode})" return {"status": "error", "error": f"rdap_lookup: {err}", "target": target} result: dict = { "status": "ok", "target": target, "raw": raw, "data": None, "handle": None, "ldhName": None, } try: data = json.loads(raw) except (ValueError, TypeError): result["warning"] = "rdap_lookup: la salida no es JSON parseable; solo se devuelve raw" return result if isinstance(data, dict): result["data"] = data result["handle"] = data.get("handle") result["ldhName"] = data.get("ldhName") else: result["data"] = data result["warning"] = "rdap_lookup: el JSON de nivel superior no es un objeto" return result if __name__ == "__main__": # Smoke test: el assert core NO depende de red — parsea un sample RDAP # JSON hardcoded reutilizando el mismo parseo de la funcion. Tras eso # intenta una consulta real, tolerando fallo de red / binario ausente. SAMPLE = json.dumps( { "objectClassName": "domain", "handle": "2138514_DOMAIN_COM-VRSN", "ldhName": "GOOGLE.COM", "status": ["client transfer prohibited"], } ) sample_data = json.loads(SAMPLE) assert sample_data["handle"] == "2138514_DOMAIN_COM-VRSN", sample_data assert sample_data["ldhName"] == "GOOGLE.COM", sample_data print("smoke parse OK") # Consulta real, best-effort (no rompe el smoke si no hay red/binario). live = rdap_lookup("google.com") print("live status:", live["status"]) if live["status"] == "ok": print(" handle:", live.get("handle")) print(" ldhName:", live.get("ldhName")) else: print(" (red no disponible o rdap fallo, tolerado):", live.get("error"))