"""HTTP proxy that routes .i2p requests through I2P tunnels. Handles both CONNECT (tunneling) and regular GET/POST (URL rewriting). Ported from net.i2p.i2ptunnel.I2PTunnelHTTPClientBase. """ from __future__ import annotations import asyncio from dataclasses import dataclass from urllib.parse import urlparse @dataclass class HTTPRequest: """Parsed HTTP request first line.""" method: str host: str port: int path: str is_connect: bool raw_first_line: str def parse_http_request(first_line: str) -> HTTPRequest: """Parse the first line of an HTTP request. Supports: - CONNECT host:port HTTP/1.x (tunnel) - METHOD http://host[:port]/path HTTP/1.x (absolute URL) """ parts = first_line.strip().split(None, 2) if len(parts) < 2: raise ValueError(f"Malformed HTTP request line: {first_line!r}") method = parts[0].upper() if method == "CONNECT": # CONNECT host:port HTTP/1.x target = parts[1] if ":" in target: host, port_str = target.rsplit(":", 1) port = int(port_str) else: host = target port = 443 return HTTPRequest( method=method, host=host, port=port, path="", is_connect=True, raw_first_line=first_line, ) # Regular request with absolute URL: GET http://host[:port]/path HTTP/1.x url = parts[1] parsed = urlparse(url) host = parsed.hostname or "" port = parsed.port or 80 path = parsed.path or "/" return HTTPRequest( method=method, host=host, port=port, path=path, is_connect=False, raw_first_line=first_line, ) def extract_i2p_destination(host: str) -> str | None: """Extract .i2p destination from hostname. Returns the hostname if it ends with '.i2p', otherwise None. """ if not host: return None if host.endswith(".i2p"): return host return None class HTTPProxy: """HTTP proxy that routes .i2p requests through I2P tunnels.""" def __init__(self, listen_host: str = "127.0.0.1", listen_port: int = 4444): self._host = listen_host self._port = listen_port self._running = False self._server: asyncio.Server | None = None async def start(self) -> None: """Start listening for HTTP proxy connections.""" self._server = await asyncio.start_server( self.handle_client, self._host, self._port ) self._running = True async def stop(self) -> None: """Stop the proxy server.""" if self._server is not None: self._server.close() await self._server.wait_closed() self._running = False async def handle_client( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: """Handle an incoming proxy client connection.""" try: first_line_bytes = await reader.readline() if not first_line_bytes: writer.close() return first_line = first_line_bytes.decode("utf-8", errors="replace").strip() request = parse_http_request(first_line) dest = extract_i2p_destination(request.host) if dest is None: # Not an I2P destination — return 502 writer.write(self.build_error_response(502, "Bad Gateway")) await writer.drain() writer.close() return # TODO: route through I2P tunnel to destination # For now, return 502 since we have no active I2P session writer.write(self.build_error_response(502, "Bad Gateway")) await writer.drain() writer.close() except Exception: try: writer.close() except Exception: pass def build_error_response(self, status_code: int, reason: str) -> bytes: """Build a minimal HTTP error response.""" body = f"