A Python port of the Invisible Internet Project (I2P)
1"""CONNECT-only proxy tunnel — dedicated HTTPS tunneling via I2P.
2
3Simpler than HTTPClientTask: only accepts CONNECT method, strips ALL headers,
4no keepalive, no address helpers.
5
6Ported from net.i2p.i2ptunnel.I2PTunnelConnectClient.
7"""
8
9from __future__ import annotations
10
11import logging
12
13from i2p_apps.i2ptunnel.forwarder import bridge
14from i2p_apps.i2ptunnel.http_proxy import parse_http_request, extract_i2p_destination
15from i2p_apps.i2ptunnel.tasks import ClientTunnelTask
16
17logger = logging.getLogger(__name__)
18
19
20class ConnectClientTask(ClientTunnelTask):
21 """CONNECT-only proxy for HTTPS tunneling through I2P.
22
23 Only accepts CONNECT method (returns 405 for GET/POST).
24 ALL headers are discarded before tunneling for privacy.
25 """
26
27 @property
28 def _is_connect_only(self) -> bool:
29 return True
30
31 @staticmethod
32 def _strip_all_headers(headers: dict[str, str]) -> dict[str, str]:
33 """Strip all headers — returns empty dict."""
34 return {}
35
36 async def _consume_headers(self, reader) -> dict[str, str]:
37 """Read and discard all headers."""
38 headers: dict[str, str] = {}
39 while True:
40 line = await reader.readline()
41 if not line or line == b"\r\n" or line == b"\n":
42 break
43 text = line.decode("utf-8", errors="replace").strip()
44 if ":" in text:
45 key, _, value = text.partition(":")
46 headers[key.strip()] = value.strip()
47 return headers
48
49 async def _resolve(self, hostname: str) -> str | None:
50 """Resolve an I2P hostname."""
51 if hostname.endswith(".b32.i2p"):
52 return hostname
53 if hostname.endswith(".i2p"):
54 return await self._session.lookup(hostname)
55 return hostname
56
57 async def handle_client(self, reader, writer) -> None:
58 try:
59 first_line_bytes = await reader.readline()
60 if not first_line_bytes:
61 return
62
63 first_line = first_line_bytes.decode("utf-8", errors="replace").strip()
64 try:
65 request = parse_http_request(first_line)
66 except ValueError:
67 writer.write(b"HTTP/1.1 400 Bad Request\r\n\r\n")
68 await writer.drain()
69 return
70
71 # Consume and discard all headers
72 await self._consume_headers(reader)
73
74 if not request.is_connect:
75 writer.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n")
76 await writer.drain()
77 return
78
79 dest_host = extract_i2p_destination(request.host)
80 if dest_host:
81 resolved = await self._resolve(dest_host)
82 if resolved is None:
83 writer.write(b"HTTP/1.1 503 Destination Not Found\r\n\r\n")
84 await writer.drain()
85 return
86
87 try:
88 remote_reader, remote_writer = await self._session.connect(resolved)
89 except Exception:
90 writer.write(b"HTTP/1.1 504 Connection Timeout\r\n\r\n")
91 await writer.drain()
92 return
93
94 writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
95 await writer.drain()
96 await bridge(reader, writer, remote_reader, remote_writer)
97 else:
98 writer.write(b"HTTP/1.1 503 No Outproxy\r\n\r\n")
99 await writer.drain()
100
101 except Exception:
102 logger.exception("Error in CONNECT proxy handler")