Perform a SOCKS5 handshake and return the requested (host, port). Sends protocol-level replies directly to *writer*. Returns ``None`` and leaves the connection in a closed state if negotiation fails at any step (unsupported version, method, command, or address type). Raises:
(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
)
| 20 | |
| 21 | |
| 22 | async def negotiate_socks5( |
| 23 | reader: asyncio.StreamReader, |
| 24 | writer: asyncio.StreamWriter, |
| 25 | ) -> tuple[str, int] | None: |
| 26 | """Perform a SOCKS5 handshake and return the requested (host, port). |
| 27 | |
| 28 | Sends protocol-level replies directly to *writer*. Returns ``None`` |
| 29 | and leaves the connection in a closed state if negotiation fails at |
| 30 | any step (unsupported version, method, command, or address type). |
| 31 | |
| 32 | Raises: |
| 33 | asyncio.IncompleteReadError: if the client closes the connection |
| 34 | mid-handshake. |
| 35 | asyncio.TimeoutError: propagated from the individual ``wait_for`` |
| 36 | calls so the caller can log it separately. |
| 37 | """ |
| 38 | # ── Auth negotiation ────────────────────────────────────────── |
| 39 | header = await asyncio.wait_for(reader.readexactly(2), timeout=15) |
| 40 | ver, nmethods = header[0], header[1] |
| 41 | if ver != 5: |
| 42 | return None |
| 43 | |
| 44 | methods = await asyncio.wait_for(reader.readexactly(nmethods), timeout=10) |
| 45 | if 0x00 not in methods: |
| 46 | # No acceptable method — reject |
| 47 | writer.write(b"\x05\xff") |
| 48 | await writer.drain() |
| 49 | return None |
| 50 | |
| 51 | # Accept: no authentication required |
| 52 | writer.write(b"\x05\x00") |
| 53 | await writer.drain() |
| 54 | |
| 55 | # ── Request ─────────────────────────────────────────────────── |
| 56 | req = await asyncio.wait_for(reader.readexactly(4), timeout=15) |
| 57 | ver, cmd, _rsv, atyp = req |
| 58 | if ver != 5 or cmd != 0x01: |
| 59 | # Only CONNECT (0x01) is supported |
| 60 | writer.write(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00") |
| 61 | await writer.drain() |
| 62 | return None |
| 63 | |
| 64 | # ── Address parsing ─────────────────────────────────────────── |
| 65 | if atyp == 0x01: # IPv4 |
| 66 | raw = await asyncio.wait_for(reader.readexactly(4), timeout=10) |
| 67 | host = socket.inet_ntoa(raw) |
| 68 | elif atyp == 0x03: # Domain name |
| 69 | ln = (await asyncio.wait_for(reader.readexactly(1), timeout=10))[0] |
| 70 | host = ( |
| 71 | await asyncio.wait_for(reader.readexactly(ln), timeout=10) |
| 72 | ).decode(errors="replace") |
| 73 | elif atyp == 0x04: # IPv6 |
| 74 | raw = await asyncio.wait_for(reader.readexactly(16), timeout=10) |
| 75 | host = socket.inet_ntop(socket.AF_INET6, raw) |
| 76 | else: |
| 77 | writer.write(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00") |
| 78 | await writer.drain() |
| 79 | return None |