A Python port of the Invisible Internet Project (I2P)
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)