A Python port of the Invisible Internet Project (I2P)
at main 159 lines 4.6 kB view raw
1"""HTTP proxy that routes .i2p requests through I2P tunnels. 2 3Handles both CONNECT (tunneling) and regular GET/POST (URL rewriting). 4Ported from net.i2p.i2ptunnel.I2PTunnelHTTPClientBase. 5""" 6 7from __future__ import annotations 8 9import asyncio 10from dataclasses import dataclass 11from urllib.parse import urlparse 12 13 14@dataclass 15class HTTPRequest: 16 """Parsed HTTP request first line.""" 17 18 method: str 19 host: str 20 port: int 21 path: str 22 is_connect: bool 23 raw_first_line: str 24 25 26def parse_http_request(first_line: str) -> HTTPRequest: 27 """Parse the first line of an HTTP request. 28 29 Supports: 30 - CONNECT host:port HTTP/1.x (tunnel) 31 - METHOD http://host[:port]/path HTTP/1.x (absolute URL) 32 """ 33 parts = first_line.strip().split(None, 2) 34 if len(parts) < 2: 35 raise ValueError(f"Malformed HTTP request line: {first_line!r}") 36 37 method = parts[0].upper() 38 39 if method == "CONNECT": 40 # CONNECT host:port HTTP/1.x 41 target = parts[1] 42 if ":" in target: 43 host, port_str = target.rsplit(":", 1) 44 port = int(port_str) 45 else: 46 host = target 47 port = 443 48 return HTTPRequest( 49 method=method, 50 host=host, 51 port=port, 52 path="", 53 is_connect=True, 54 raw_first_line=first_line, 55 ) 56 57 # Regular request with absolute URL: GET http://host[:port]/path HTTP/1.x 58 url = parts[1] 59 parsed = urlparse(url) 60 host = parsed.hostname or "" 61 port = parsed.port or 80 62 path = parsed.path or "/" 63 64 return HTTPRequest( 65 method=method, 66 host=host, 67 port=port, 68 path=path, 69 is_connect=False, 70 raw_first_line=first_line, 71 ) 72 73 74def extract_i2p_destination(host: str) -> str | None: 75 """Extract .i2p destination from hostname. 76 77 Returns the hostname if it ends with '.i2p', otherwise None. 78 """ 79 if not host: 80 return None 81 if host.endswith(".i2p"): 82 return host 83 return None 84 85 86class HTTPProxy: 87 """HTTP proxy that routes .i2p requests through I2P tunnels.""" 88 89 def __init__(self, listen_host: str = "127.0.0.1", listen_port: int = 4444): 90 self._host = listen_host 91 self._port = listen_port 92 self._running = False 93 self._server: asyncio.Server | None = None 94 95 async def start(self) -> None: 96 """Start listening for HTTP proxy connections.""" 97 self._server = await asyncio.start_server( 98 self.handle_client, self._host, self._port 99 ) 100 self._running = True 101 102 async def stop(self) -> None: 103 """Stop the proxy server.""" 104 if self._server is not None: 105 self._server.close() 106 await self._server.wait_closed() 107 self._running = False 108 109 async def handle_client( 110 self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter 111 ) -> None: 112 """Handle an incoming proxy client connection.""" 113 try: 114 first_line_bytes = await reader.readline() 115 if not first_line_bytes: 116 writer.close() 117 return 118 119 first_line = first_line_bytes.decode("utf-8", errors="replace").strip() 120 request = parse_http_request(first_line) 121 dest = extract_i2p_destination(request.host) 122 123 if dest is None: 124 # Not an I2P destination — return 502 125 writer.write(self.build_error_response(502, "Bad Gateway")) 126 await writer.drain() 127 writer.close() 128 return 129 130 # TODO: route through I2P tunnel to destination 131 # For now, return 502 since we have no active I2P session 132 writer.write(self.build_error_response(502, "Bad Gateway")) 133 await writer.drain() 134 writer.close() 135 except Exception: 136 try: 137 writer.close() 138 except Exception: 139 pass 140 141 def build_error_response(self, status_code: int, reason: str) -> bytes: 142 """Build a minimal HTTP error response.""" 143 body = f"<html><body><h1>{status_code} {reason}</h1></body></html>" 144 return ( 145 f"HTTP/1.1 {status_code} {reason}\r\n" 146 f"Content-Type: text/html\r\n" 147 f"Content-Length: {len(body)}\r\n" 148 f"Connection: close\r\n" 149 f"\r\n" 150 f"{body}" 151 ).encode("utf-8") 152 153 @property 154 def is_running(self) -> bool: 155 return self._running 156 157 @property 158 def listen_address(self) -> tuple[str, int]: 159 return (self._host, self._port)