# PATH: bot/integrations/http.py import ssl import json as _json from typing import Any, Dict, Optional, Tuple, List from urllib.parse import urlparse, urlunparse import httpx _DEFAULT_TIMEOUT = httpx.Timeout(20.0, connect=10.0) _client: Optional[httpx.AsyncClient] = None _client_insecure: Optional[httpx.AsyncClient] = None def get_client() -> httpx.AsyncClient: global _client if _client is None: _client = httpx.AsyncClient( timeout=_DEFAULT_TIMEOUT, follow_redirects=True, http2=True, ) return _client def get_client_insecure() -> httpx.AsyncClient: """ Insecure client used only for IP fallback (TLS verify off), because we connect to https:// with Host header. """ global _client_insecure if _client_insecure is None: _client_insecure = httpx.AsyncClient( timeout=_DEFAULT_TIMEOUT, follow_redirects=True, http2=True, verify=False, ) return _client_insecure def _is_dns_error(e: Exception) -> bool: s = str(e).lower() return ("no address associated with hostname" in s) or ("gaierror" in s) or ("name or service not known" in s) async def doh_resolve_a(host: str) -> List[str]: """ Resolve A records via DNS-over-HTTPS. Uses dns.google (since google.com resolves fine in your env). """ c = get_client() try: r = await c.get("https://dns.google/resolve", params={"name": host, "type": "A"}) if r.status_code != 200: return [] j = r.json() ans = j.get("Answer") or [] ips = [] for a in ans: if a.get("type") == 1 and a.get("data"): ips.append(str(a["data"])) # unique keep order out = [] for ip in ips: if ip not in out: out.append(ip) return out except Exception: return [] def _split_url(url: str) -> Tuple[str, str, str]: """ Returns (scheme, host, rest(path+query+fragment)) """ u = urlparse(url) scheme = u.scheme or "https" host = u.netloc rest = urlunparse(("", "", u.path or "/", u.params, u.query, u.fragment)) return scheme, host, rest async def fetch_status(url: str) -> str: """ Returns status code as string, or ERR:... Uses same DNS fallback logic. """ try: r = await request_text("GET", url) return str(r[0]) except Exception as e: return f"ERR:{type(e).__name__}:{e}" async def request_text(method: str, url: str, headers: Optional[Dict[str, str]] = None) -> Tuple[int, str]: """ Low-level text request with DNS fallback. Returns (status_code, text) """ headers = dict(headers or {}) c = get_client() try: r = await c.request(method, url, headers=headers) return r.status_code, r.text except Exception as e: # DNS fail -> DoH fallback if not _is_dns_error(e): raise scheme, host, rest = _split_url(url) if not host: raise ips = await doh_resolve_a(host) if not ips: raise ic = get_client_insecure() last_err = e for ip in ips: try: # connect to IP but keep routing via Host header h2 = dict(headers) h2["Host"] = host r = await ic.request(method, f"{scheme}://{ip}{rest}", headers=h2) return r.status_code, r.text except Exception as e2: last_err = e2 continue raise last_err async def post_json(url: str, headers: Optional[Dict[str, str]] = None, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ POST JSON helper with DNS fallback. Returns dict: {ok, status, data?, err?} """ headers = dict(headers or {}) headers.setdefault("Content-Type", "application/json") c = get_client() payload = payload or {} try: r = await c.post(url, headers=headers, json=payload) try: data = r.json() except Exception: data = {"raw": r.text} return {"ok": r.status_code < 400, "status": r.status_code, "data": data} except Exception as e: if not _is_dns_error(e): return {"ok": False, "status": 0, "err": str(e)} # DNS fail -> DoH -> IP fallback (verify off + Host header) scheme, host, rest = _split_url(url) if not host: return {"ok": False, "status": 0, "err": str(e)} ips = await doh_resolve_a(host) if not ips: return {"ok": False, "status": 0, "err": str(e)} ic = get_client_insecure() last_err: Exception = e for ip in ips: try: h2 = dict(headers) h2["Host"] = host r = await ic.post(f"{scheme}://{ip}{rest}", headers=h2, json=payload) try: data = r.json() except Exception: data = {"raw": r.text} return {"ok": r.status_code < 400, "status": r.status_code, "data": data} except Exception as e2: last_err = e2 continue return {"ok": False, "status": 0, "err": str(last_err)}