hexproxy 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hexproxy/proxy.py ADDED
@@ -0,0 +1,1178 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ import errno
6
+ import re
7
+ import select
8
+ import socket
9
+ import ssl
10
+ import threading
11
+ from typing import Iterable
12
+ from urllib.parse import urlsplit
13
+
14
+ from .bodyview import normalize_http_body
15
+ from .certs import CertificateAuthority, default_certificate_dir
16
+ from .extensions import HookContext, PluginManager
17
+ from .models import HeaderList, MatchReplaceRule, RequestData, ResponseData
18
+ from .store import TrafficStore
19
+
20
+
21
+ MAX_HEADER_BYTES = 1024 * 1024
22
+ LOCAL_PROXY_HOSTS = {"hexproxy", "hexproxy.local", "localhost", "127.0.0.1"}
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class ParsedRequest:
27
+ method: str
28
+ target: str
29
+ version: str
30
+ headers: HeaderList
31
+ body: bytes
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class ParsedResponse:
36
+ version: str
37
+ status_code: int
38
+ reason: str
39
+ headers: HeaderList
40
+ body: bytes
41
+ raw: bytes
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class UpstreamTarget:
46
+ host: str
47
+ port: int
48
+ path: str
49
+ tls: bool = False
50
+
51
+
52
+ class BufferedSocketReader:
53
+ def __init__(self, sock: socket.socket) -> None:
54
+ self.sock = sock
55
+ self.buffer = bytearray()
56
+
57
+ def readuntil(self, marker: bytes) -> bytes:
58
+ while True:
59
+ index = self.buffer.find(marker)
60
+ if index >= 0:
61
+ end = index + len(marker)
62
+ chunk = bytes(self.buffer[:end])
63
+ del self.buffer[:end]
64
+ return chunk
65
+
66
+ data = self.sock.recv(65536)
67
+ if not data:
68
+ raise asyncio.IncompleteReadError(partial=bytes(self.buffer), expected=None)
69
+ self.buffer.extend(data)
70
+
71
+ def readexactly(self, length: int) -> bytes:
72
+ while len(self.buffer) < length:
73
+ data = self.sock.recv(65536)
74
+ if not data:
75
+ raise asyncio.IncompleteReadError(partial=bytes(self.buffer), expected=length)
76
+ self.buffer.extend(data)
77
+
78
+ chunk = bytes(self.buffer[:length])
79
+ del self.buffer[:length]
80
+ return chunk
81
+
82
+ def read(self) -> bytes:
83
+ chunks = [bytes(self.buffer)] if self.buffer else []
84
+ self.buffer.clear()
85
+ while True:
86
+ data = self.sock.recv(65536)
87
+ if not data:
88
+ return b"".join(chunks)
89
+ chunks.append(data)
90
+
91
+
92
+ def parse_request_text(raw_request: str) -> ParsedRequest:
93
+ normalized = raw_request.replace("\r\n", "\n").replace("\r", "\n")
94
+ if "\n\n" not in normalized:
95
+ raise ValueError("request is missing the blank line between headers and body")
96
+
97
+ head, body_text = normalized.split("\n\n", 1)
98
+ lines = head.split("\n")
99
+ if not lines or not lines[0].strip():
100
+ raise ValueError("request line is missing")
101
+
102
+ try:
103
+ method, target, version = lines[0].split(" ", 2)
104
+ except ValueError as exc:
105
+ raise ValueError(f"invalid request line: {lines[0]!r}") from exc
106
+
107
+ headers = HttpProxyServer._parse_headers(lines[1:])
108
+ return ParsedRequest(
109
+ method=method,
110
+ target=target,
111
+ version=version,
112
+ headers=headers,
113
+ body=body_text.encode("iso-8859-1"),
114
+ )
115
+
116
+
117
+ def render_request_text(request: ParsedRequest) -> str:
118
+ lines = [f"{request.method} {request.target} {request.version}"]
119
+ lines.extend(f"{name}: {value}" for name, value in request.headers)
120
+ head = "\n".join(lines)
121
+ body = request.body.decode("iso-8859-1", errors="replace")
122
+ return f"{head}\n\n{body}"
123
+
124
+
125
+ def parse_response_text(raw_response: str) -> ParsedResponse:
126
+ normalized = raw_response.replace("\r\n", "\n").replace("\r", "\n")
127
+ if "\n\n" not in normalized:
128
+ raise ValueError("response is missing the blank line between headers and body")
129
+
130
+ head, body_text = normalized.split("\n\n", 1)
131
+ lines = head.split("\n")
132
+ if not lines or not lines[0].strip():
133
+ raise ValueError("status line is missing")
134
+
135
+ try:
136
+ version, status_code, reason = lines[0].split(" ", 2)
137
+ except ValueError:
138
+ try:
139
+ version, status_code = lines[0].split(" ", 1)
140
+ except ValueError as exc:
141
+ raise ValueError(f"invalid status line: {lines[0]!r}") from exc
142
+ reason = ""
143
+
144
+ headers = HttpProxyServer._parse_headers(lines[1:])
145
+ response = ParsedResponse(
146
+ version=version,
147
+ status_code=int(status_code),
148
+ reason=reason,
149
+ headers=headers,
150
+ body=body_text.encode("iso-8859-1"),
151
+ raw=b"",
152
+ )
153
+ response.raw = render_response_bytes(response)
154
+ return response
155
+
156
+
157
+ def render_response_text(response: ParsedResponse) -> str:
158
+ status_line = f"{response.version} {response.status_code}"
159
+ if response.reason:
160
+ status_line = f"{status_line} {response.reason}"
161
+ lines = [status_line]
162
+ lines.extend(f"{name}: {value}" for name, value in response.headers)
163
+ head = "\n".join(lines)
164
+ body = response.body.decode("iso-8859-1", errors="replace")
165
+ return f"{head}\n\n{body}"
166
+
167
+
168
+ def render_response_bytes(response: ParsedResponse) -> bytes:
169
+ headers: list[tuple[str, str]] = []
170
+ has_content_length = False
171
+ chunked = False
172
+
173
+ for name, value in response.headers:
174
+ lower_name = name.lower()
175
+ if lower_name == "content-length":
176
+ has_content_length = True
177
+ continue
178
+ if lower_name == "transfer-encoding" and "chunked" in value.lower():
179
+ chunked = True
180
+ headers.append((name, value))
181
+
182
+ if not chunked and (response.body or has_content_length):
183
+ headers.append(("Content-Length", str(len(response.body))))
184
+
185
+ status_line = f"{response.version} {response.status_code}"
186
+ if response.reason:
187
+ status_line = f"{status_line} {response.reason}"
188
+ lines = [status_line]
189
+ lines.extend(f"{name}: {value}" for name, value in headers)
190
+ return "\r\n".join(lines).encode("iso-8859-1") + b"\r\n\r\n" + response.body
191
+
192
+
193
+ class HttpProxyServer:
194
+ def __init__(
195
+ self,
196
+ store: TrafficStore,
197
+ listen_host: str = "127.0.0.1",
198
+ listen_port: int = 8080,
199
+ plugins: PluginManager | None = None,
200
+ certificate_authority: CertificateAuthority | None = None,
201
+ ) -> None:
202
+ self.store = store
203
+ self.listen_host = listen_host
204
+ self.listen_port = listen_port
205
+ self.plugins = plugins or PluginManager()
206
+ self.certificate_authority = certificate_authority or CertificateAuthority(default_certificate_dir())
207
+ self._server: asyncio.base_events.Server | None = None
208
+ self.startup_notice = ""
209
+ self._state_lock = threading.Lock()
210
+ self._client_writers: set[asyncio.StreamWriter] = set()
211
+ self._mitm_client_sockets: set[socket.socket] = set()
212
+
213
+ async def start(self) -> None:
214
+ requested_port = self.listen_port
215
+ candidate_ports = [0] if requested_port == 0 else [*range(requested_port, requested_port + 10), 0]
216
+ last_error: OSError | None = None
217
+
218
+ for candidate_port in candidate_ports:
219
+ try:
220
+ self._server = await asyncio.start_server(self._handle_client, self.listen_host, candidate_port)
221
+ except OSError as exc:
222
+ last_error = exc
223
+ if exc.errno != errno.EADDRINUSE or requested_port == 0:
224
+ raise
225
+ continue
226
+
227
+ socket = self._server.sockets[0]
228
+ self.listen_host, self.listen_port = socket.getsockname()[:2]
229
+ self.startup_notice = ""
230
+ if requested_port != 0 and self.listen_port != requested_port:
231
+ self.startup_notice = (
232
+ f"Port {requested_port} was busy. HexProxy is listening on {self.listen_host}:{self.listen_port}."
233
+ )
234
+ return
235
+
236
+ if last_error is not None and last_error.errno == errno.EADDRINUSE:
237
+ tried = ", ".join("auto" if port == 0 else str(port) for port in candidate_ports)
238
+ raise RuntimeError(
239
+ f"unable to bind {self.listen_host}; tried ports {tried} and all are already in use"
240
+ ) from last_error
241
+ if last_error is not None:
242
+ raise last_error
243
+ raise RuntimeError("failed to start proxy server")
244
+
245
+ async def serve_forever(self) -> None:
246
+ if self._server is None:
247
+ raise RuntimeError("proxy server not started")
248
+ async with self._server:
249
+ await self._server.serve_forever()
250
+
251
+ async def stop(self) -> None:
252
+ self.store.release_pending_interceptions()
253
+ server = self._server
254
+ self._server = None
255
+ if server is not None:
256
+ server.close()
257
+ await server.wait_closed()
258
+
259
+ with self._state_lock:
260
+ client_writers = list(self._client_writers)
261
+ mitm_sockets = list(self._mitm_client_sockets)
262
+
263
+ for writer in client_writers:
264
+ writer.close()
265
+ if client_writers:
266
+ await asyncio.gather(*(writer.wait_closed() for writer in client_writers), return_exceptions=True)
267
+
268
+ for sock in mitm_sockets:
269
+ try:
270
+ sock.shutdown(socket.SHUT_RDWR)
271
+ except OSError:
272
+ pass
273
+ try:
274
+ sock.close()
275
+ except OSError:
276
+ pass
277
+
278
+ async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
279
+ self._register_client_writer(writer)
280
+ peername = writer.get_extra_info("peername")
281
+ client_addr = self._format_peer(peername)
282
+ entry_id = self.store.create_entry(client_addr)
283
+ context = HookContext(
284
+ entry_id=entry_id,
285
+ client_addr=client_addr,
286
+ store=self.store,
287
+ plugin_manager=self.plugins,
288
+ )
289
+ response_sent = False
290
+
291
+ try:
292
+ request = await self._read_request(reader)
293
+ local_response = self._build_local_response(request)
294
+ if local_response is not None:
295
+ local_target = UpstreamTarget(host="hexproxy", port=80, path=self._request_path(request))
296
+ self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, local_target))
297
+ writer.write(local_response.raw)
298
+ await writer.drain()
299
+ self.store.mutate(entry_id, lambda entry: self._record_response(entry, local_response, local_target))
300
+ response_sent = True
301
+ return
302
+ if request.method.upper() == "CONNECT":
303
+ response_sent = True
304
+ await self._handle_connect_tunnel(
305
+ reader=reader,
306
+ writer=writer,
307
+ entry_id=entry_id,
308
+ context=context,
309
+ connect_request=request,
310
+ )
311
+ return
312
+ await self._forward_exchange(
313
+ client_reader=reader,
314
+ client_writer=writer,
315
+ request=request,
316
+ entry_id=entry_id,
317
+ context=context,
318
+ )
319
+ response_sent = True
320
+ except asyncio.IncompleteReadError as exc:
321
+ message = self._describe_incomplete_read(exc)
322
+ self.store.mutate(entry_id, lambda entry: self._record_error(entry, message))
323
+ if not response_sent and not self._looks_like_tls_handshake(exc.partial):
324
+ await self._write_simple_response(writer, 400, "Bad Request", b"Malformed HTTP message.\n")
325
+ except Exception as exc:
326
+ self.plugins.on_error(context, exc)
327
+ self.plugins.persist_hook_context(context)
328
+ self.store.mutate(entry_id, lambda entry: self._record_error(entry, str(exc)))
329
+ if not response_sent:
330
+ await self._write_simple_response(writer, 502, "Bad Gateway", b"Upstream request failed.\n")
331
+ finally:
332
+ self._unregister_client_writer(writer)
333
+ writer.close()
334
+ await writer.wait_closed()
335
+ self.store.complete(entry_id)
336
+
337
+ async def _read_request(self, reader: asyncio.StreamReader) -> ParsedRequest:
338
+ head = await self._read_head(reader)
339
+ lines = head.decode("iso-8859-1").split("\r\n")
340
+ headers = self._parse_headers(lines[1:])
341
+ body = await self._read_body(reader, headers)
342
+ return parse_request_text((head + b"\r\n\r\n" + body).decode("iso-8859-1"))
343
+
344
+ async def _read_response(self, reader: asyncio.StreamReader, request: ParsedRequest | None = None) -> ParsedResponse:
345
+ head = await self._read_head(reader)
346
+ lines = head.decode("iso-8859-1").split("\r\n")
347
+ status_line = lines[0]
348
+ try:
349
+ version, status_code, reason = status_line.split(" ", 2)
350
+ except ValueError:
351
+ version, status_code = status_line.split(" ", 1)
352
+ reason = ""
353
+
354
+ headers = self._parse_headers(lines[1:])
355
+ parsed_status_code = int(status_code)
356
+ if self._response_has_body(parsed_status_code, headers, request):
357
+ body = await self._read_body(reader, headers, is_response=True)
358
+ else:
359
+ body = b""
360
+ return ParsedResponse(
361
+ version=version,
362
+ status_code=parsed_status_code,
363
+ reason=reason,
364
+ headers=headers,
365
+ body=body,
366
+ raw=head + b"\r\n\r\n" + body,
367
+ )
368
+
369
+ async def _read_head(self, reader: asyncio.StreamReader) -> bytes:
370
+ try:
371
+ raw = await reader.readuntil(b"\r\n\r\n")
372
+ except asyncio.LimitOverrunError as exc:
373
+ raise ValueError("header section too large") from exc
374
+ if len(raw) > MAX_HEADER_BYTES:
375
+ raise ValueError("header section too large")
376
+ return raw[:-4]
377
+
378
+ async def _read_body(self, reader: asyncio.StreamReader, headers: HeaderList, is_response: bool = False) -> bytes:
379
+ header_map = {name.lower(): value for name, value in headers}
380
+ transfer_encoding = header_map.get("transfer-encoding", "").lower()
381
+
382
+ if "chunked" in transfer_encoding:
383
+ return await self._read_chunked_body(reader)
384
+
385
+ content_length = header_map.get("content-length")
386
+ if content_length is not None:
387
+ length = int(content_length)
388
+ if length == 0:
389
+ return b""
390
+ return await reader.readexactly(length)
391
+
392
+ if is_response:
393
+ return await reader.read()
394
+
395
+ return b""
396
+
397
+ async def _read_chunked_body(self, reader: asyncio.StreamReader) -> bytes:
398
+ chunks: list[bytes] = []
399
+ while True:
400
+ line = await reader.readuntil(b"\r\n")
401
+ chunks.append(line)
402
+ chunk_size = int(line.split(b";", 1)[0].strip(), 16)
403
+ if chunk_size == 0:
404
+ trailer = await reader.readuntil(b"\r\n")
405
+ chunks.append(trailer)
406
+ break
407
+ chunk = await reader.readexactly(chunk_size + 2)
408
+ chunks.append(chunk)
409
+ return b"".join(chunks)
410
+
411
+ def _resolve_target(self, request: ParsedRequest) -> UpstreamTarget:
412
+ if request.method.upper() == "CONNECT":
413
+ return self._resolve_connect_target(request)
414
+
415
+ lowered_target = request.target.lower()
416
+ if lowered_target.startswith(("http://", "https://", "ws://", "wss://")):
417
+ parsed = urlsplit(request.target)
418
+ host = parsed.hostname
419
+ if not host:
420
+ raise ValueError("request target does not include a host")
421
+ tls = parsed.scheme.lower() in {"https", "wss"}
422
+ port = parsed.port or (443 if tls else 80)
423
+ path = self._origin_form(parsed.path, parsed.query)
424
+ return UpstreamTarget(host=host, port=port, path=path, tls=tls)
425
+
426
+ host_header = self._find_header(request.headers, "Host")
427
+ if not host_header:
428
+ raise ValueError("missing Host header")
429
+ if ":" in host_header:
430
+ host, port_text = host_header.rsplit(":", 1)
431
+ port = int(port_text)
432
+ else:
433
+ host = host_header
434
+ port = 80
435
+ return UpstreamTarget(host=host, port=port, path=request.target or "/")
436
+
437
+ def _resolve_connect_target(self, request: ParsedRequest) -> UpstreamTarget:
438
+ if ":" not in request.target:
439
+ raise ValueError("CONNECT target must be host:port")
440
+ host, port_text = request.target.rsplit(":", 1)
441
+ if not host:
442
+ raise ValueError("CONNECT target host is missing")
443
+ return UpstreamTarget(host=host, port=int(port_text), path="/", tls=True)
444
+
445
+ def _build_upstream_request(self, request: ParsedRequest, target: UpstreamTarget) -> bytes:
446
+ headers: list[tuple[str, str]] = []
447
+ websocket_upgrade = self._is_websocket_request(request)
448
+ skip = {"proxy-connection", "content-length", "accept-encoding"}
449
+ if not websocket_upgrade:
450
+ skip.add("connection")
451
+ host_header_written = False
452
+ has_content_length = False
453
+ chunked = False
454
+ default_port = 443 if target.tls else 80
455
+
456
+ for name, value in request.headers:
457
+ lower_name = name.lower()
458
+ if lower_name in skip:
459
+ if lower_name == "content-length":
460
+ has_content_length = True
461
+ continue
462
+ if lower_name == "transfer-encoding" and "chunked" in value.lower():
463
+ chunked = True
464
+ if lower_name == "host":
465
+ host_header_written = True
466
+ value = target.host if target.port == default_port else f"{target.host}:{target.port}"
467
+ headers.append((name, value))
468
+
469
+ if not host_header_written:
470
+ host_value = target.host if target.port == default_port else f"{target.host}:{target.port}"
471
+ headers.append(("Host", host_value))
472
+ if not websocket_upgrade:
473
+ headers.append(("Accept-Encoding", "identity"))
474
+ if not chunked and (request.body or has_content_length):
475
+ headers.append(("Content-Length", str(len(request.body))))
476
+ if not websocket_upgrade:
477
+ headers.append(("Connection", "close"))
478
+
479
+ lines = [f"{request.method} {target.path} {request.version}"]
480
+ lines.extend(f"{name}: {value}" for name, value in headers)
481
+ head = "\r\n".join(lines).encode("iso-8859-1") + b"\r\n\r\n"
482
+ return head + request.body
483
+
484
+ async def _forward_exchange(
485
+ self,
486
+ client_reader: asyncio.StreamReader,
487
+ client_writer: asyncio.StreamWriter,
488
+ request: ParsedRequest,
489
+ entry_id: int,
490
+ context: HookContext,
491
+ fixed_target: UpstreamTarget | None = None,
492
+ ) -> None:
493
+ target = fixed_target or self._resolve_target(request)
494
+ self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, target))
495
+
496
+ if self.store.begin_interception(entry_id, "request", render_request_text(request), host=target.host):
497
+ interception = await asyncio.to_thread(self.store.wait_for_interception, entry_id)
498
+ if interception.decision == "drop":
499
+ await self._write_simple_response(client_writer, 403, "Forbidden", b"Request dropped by interceptor.\n")
500
+ return
501
+ request = parse_request_text(interception.raw_text)
502
+ if fixed_target is not None:
503
+ target = self._target_for_fixed_tunnel(request, fixed_target)
504
+ else:
505
+ target = self._resolve_target(request)
506
+ self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, target))
507
+
508
+ request = self.plugins.before_request_forward(context, request)
509
+ request = self._apply_match_replace_to_request(request)
510
+ if fixed_target is not None:
511
+ target = self._target_for_fixed_tunnel(request, fixed_target)
512
+ else:
513
+ target = self._resolve_target(request)
514
+ self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, target))
515
+
516
+ upstream_reader, upstream_writer = await self._open_upstream_connection(target)
517
+ try:
518
+ upstream_request = self._build_upstream_request(request, target)
519
+ upstream_writer.write(upstream_request)
520
+ await upstream_writer.drain()
521
+
522
+ response = await self._read_response(upstream_reader, request=request)
523
+ self.plugins.on_response_received(context, request, response)
524
+ self.plugins.persist_hook_context(context)
525
+ response = self._apply_match_replace_to_response(response)
526
+ self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, target))
527
+
528
+ editable_response = self._response_for_interception(response)
529
+ if self.store.begin_interception(entry_id, "response", render_response_text(editable_response), host=target.host):
530
+ interception = await asyncio.to_thread(self.store.wait_for_interception, entry_id)
531
+ if interception.decision == "drop":
532
+ dropped_response = self._build_static_response(
533
+ status_code=502,
534
+ reason="Bad Gateway",
535
+ headers=[("Content-Type", "text/plain; charset=utf-8")],
536
+ body=b"Response dropped by interceptor.\n",
537
+ )
538
+ client_writer.write(dropped_response.raw)
539
+ await client_writer.drain()
540
+ self.store.mutate(entry_id, lambda entry: self._record_error(entry, "response dropped by interceptor"))
541
+ return
542
+ response = parse_response_text(interception.raw_text)
543
+ self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, target))
544
+
545
+ client_writer.write(response.raw)
546
+ await client_writer.drain()
547
+ if self._is_websocket_upgrade(request, response):
548
+ self.store.mutate(entry_id, lambda entry: self._mark_streaming(entry))
549
+ await self._relay_bidirectional(client_reader, client_writer, upstream_reader, upstream_writer)
550
+ self.store.mutate(entry_id, lambda entry: self._mark_complete(entry))
551
+ finally:
552
+ upstream_writer.close()
553
+ await upstream_writer.wait_closed()
554
+
555
+ async def _handle_connect_tunnel(
556
+ self,
557
+ reader: asyncio.StreamReader,
558
+ writer: asyncio.StreamWriter,
559
+ entry_id: int,
560
+ context: HookContext,
561
+ connect_request: ParsedRequest,
562
+ ) -> None:
563
+ target = self._resolve_connect_target(connect_request)
564
+ self.store.mutate(entry_id, lambda entry: self._record_request(entry, connect_request, target))
565
+ await self._write_connect_established(writer)
566
+ response = self._build_static_response(
567
+ status_code=200,
568
+ reason="Connection Established",
569
+ headers=[("Connection", "keep-alive")],
570
+ body=b"",
571
+ )
572
+ self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, target))
573
+ self.store.mutate(entry_id, lambda entry: self._mark_streaming(entry))
574
+
575
+ transport = getattr(writer, "_transport", None)
576
+ raw_socket = writer.get_extra_info("socket")
577
+ if raw_socket is None or transport is None:
578
+ raise RuntimeError("unable to access the underlying CONNECT socket")
579
+ transport.pause_reading()
580
+ client_socket = raw_socket.dup()
581
+ client_socket.setblocking(True)
582
+ self._register_mitm_socket(client_socket)
583
+
584
+ try:
585
+ await asyncio.to_thread(self._run_connect_mitm_session, client_socket, target, context.client_addr)
586
+ finally:
587
+ self._unregister_mitm_socket(client_socket)
588
+ self.store.mutate(entry_id, lambda entry: self._mark_complete(entry))
589
+
590
+ async def _open_upstream_connection(self, target: UpstreamTarget) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
591
+ if target.tls:
592
+ ssl_context = ssl._create_unverified_context()
593
+ return await asyncio.open_connection(target.host, target.port, ssl=ssl_context, server_hostname=target.host)
594
+ return await asyncio.open_connection(target.host, target.port)
595
+
596
+ async def replay_request(self, raw_request: str) -> str:
597
+ request = parse_request_text(raw_request)
598
+ if request.method.upper() == "CONNECT":
599
+ raise ValueError("repeater does not support CONNECT requests")
600
+ if self._is_websocket_request(request):
601
+ raise ValueError("repeater does not support WebSocket upgrade requests")
602
+
603
+ target = self._resolve_target(request)
604
+ upstream_reader, upstream_writer = await self._open_upstream_connection(target)
605
+ try:
606
+ upstream_writer.write(self._build_upstream_request(request, target))
607
+ await upstream_writer.drain()
608
+ response = await self._read_response(upstream_reader, request=request)
609
+ return render_response_text(self._response_for_interception(response))
610
+ finally:
611
+ upstream_writer.close()
612
+ await upstream_writer.wait_closed()
613
+
614
+ async def _relay_bidirectional(
615
+ self,
616
+ client_reader: asyncio.StreamReader,
617
+ client_writer: asyncio.StreamWriter,
618
+ upstream_reader: asyncio.StreamReader,
619
+ upstream_writer: asyncio.StreamWriter,
620
+ ) -> None:
621
+ async def _pipe(source: asyncio.StreamReader, destination: asyncio.StreamWriter) -> None:
622
+ while True:
623
+ chunk = await source.read(65536)
624
+ if not chunk:
625
+ break
626
+ destination.write(chunk)
627
+ await destination.drain()
628
+ try:
629
+ destination.write_eof()
630
+ except (AttributeError, OSError, RuntimeError):
631
+ pass
632
+
633
+ await asyncio.gather(
634
+ _pipe(client_reader, upstream_writer),
635
+ _pipe(upstream_reader, client_writer),
636
+ )
637
+
638
+ def _run_connect_mitm_session(
639
+ self,
640
+ client_socket: socket.socket,
641
+ connect_target: UpstreamTarget,
642
+ client_addr: str,
643
+ ) -> None:
644
+ try:
645
+ try:
646
+ client_tls = self._wrap_client_tls_socket(client_socket, connect_target.host)
647
+ except ssl.SSLError as exc:
648
+ if self._is_client_certificate_rejection(exc):
649
+ raise RuntimeError(
650
+ "client rejected the HexProxy TLS certificate; re-import the current HexProxy CA"
651
+ ) from exc
652
+ raise
653
+ client_reader = BufferedSocketReader(client_tls)
654
+
655
+ while True:
656
+ try:
657
+ request = self._read_request_from_socket(client_reader)
658
+ except asyncio.IncompleteReadError as exc:
659
+ if not exc.partial:
660
+ return
661
+ raise
662
+
663
+ entry_id = self.store.create_entry(client_addr)
664
+ context = HookContext(
665
+ entry_id=entry_id,
666
+ client_addr=client_addr,
667
+ store=self.store,
668
+ plugin_manager=self.plugins,
669
+ )
670
+ fixed_target = UpstreamTarget(
671
+ host=connect_target.host,
672
+ port=connect_target.port,
673
+ path=self._request_path(request),
674
+ tls=True,
675
+ )
676
+ self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, fixed_target))
677
+
678
+ if self.store.begin_interception(
679
+ entry_id,
680
+ "request",
681
+ render_request_text(request),
682
+ host=fixed_target.host,
683
+ ):
684
+ interception = self.store.wait_for_interception(entry_id)
685
+ if interception.decision == "drop":
686
+ response = self._build_static_response(
687
+ status_code=403,
688
+ reason="Forbidden",
689
+ headers=[("Content-Type", "text/plain; charset=utf-8")],
690
+ body=b"Request dropped by interceptor.\n",
691
+ )
692
+ client_tls.sendall(response.raw)
693
+ self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, fixed_target))
694
+ self.store.complete(entry_id)
695
+ continue
696
+ request = parse_request_text(interception.raw_text)
697
+ fixed_target = self._target_for_fixed_tunnel(request, fixed_target)
698
+ self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, fixed_target))
699
+
700
+ request = self.plugins.before_request_forward(context, request)
701
+ request = self._apply_match_replace_to_request(request)
702
+ fixed_target = self._target_for_fixed_tunnel(request, fixed_target)
703
+ self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, fixed_target))
704
+
705
+ with self._open_sync_upstream_tls_socket(connect_target.host, connect_target.port) as upstream_tls:
706
+ upstream_reader = BufferedSocketReader(upstream_tls)
707
+ upstream_tls.sendall(self._build_upstream_request(request, fixed_target))
708
+ response = self._read_response_from_socket(upstream_reader, request=request)
709
+ self.plugins.on_response_received(context, request, response)
710
+ self.plugins.persist_hook_context(context)
711
+ response = self._apply_match_replace_to_response(response)
712
+ self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, fixed_target))
713
+
714
+ editable_response = self._response_for_interception(response)
715
+ if self.store.begin_interception(
716
+ entry_id,
717
+ "response",
718
+ render_response_text(editable_response),
719
+ host=fixed_target.host,
720
+ ):
721
+ interception = self.store.wait_for_interception(entry_id)
722
+ if interception.decision == "drop":
723
+ dropped_response = self._build_static_response(
724
+ status_code=502,
725
+ reason="Bad Gateway",
726
+ headers=[("Content-Type", "text/plain; charset=utf-8")],
727
+ body=b"Response dropped by interceptor.\n",
728
+ )
729
+ client_tls.sendall(dropped_response.raw)
730
+ self.store.mutate(
731
+ entry_id,
732
+ lambda entry: self._record_error(entry, "response dropped by interceptor"),
733
+ )
734
+ self.store.complete(entry_id)
735
+ continue
736
+ response = parse_response_text(interception.raw_text)
737
+ self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, fixed_target))
738
+
739
+ client_tls.sendall(response.raw)
740
+
741
+ if self._is_websocket_upgrade(request, response):
742
+ self.store.mutate(entry_id, lambda entry: self._mark_streaming(entry))
743
+ self._relay_socket_bidirectional(client_tls, upstream_tls)
744
+ self.store.mutate(entry_id, lambda entry: self._mark_complete(entry))
745
+ self.store.complete(entry_id)
746
+ return
747
+
748
+ self.store.complete(entry_id)
749
+ finally:
750
+ client_socket.close()
751
+
752
+ @staticmethod
753
+ def _is_client_certificate_rejection(exc: ssl.SSLError) -> bool:
754
+ parts = [str(exc), *(str(part) for part in exc.args)]
755
+ message = " ".join(parts).lower().replace("_", " ")
756
+ return "bad certificate" in message or "unknown ca" in message or "certificate unknown" in message
757
+
758
+ def _register_client_writer(self, writer: asyncio.StreamWriter) -> None:
759
+ with self._state_lock:
760
+ self._client_writers.add(writer)
761
+
762
+ def _unregister_client_writer(self, writer: asyncio.StreamWriter) -> None:
763
+ with self._state_lock:
764
+ self._client_writers.discard(writer)
765
+
766
+ def _register_mitm_socket(self, sock: socket.socket) -> None:
767
+ with self._state_lock:
768
+ self._mitm_client_sockets.add(sock)
769
+
770
+ def _unregister_mitm_socket(self, sock: socket.socket) -> None:
771
+ with self._state_lock:
772
+ self._mitm_client_sockets.discard(sock)
773
+
774
+ def _wrap_client_tls_socket(self, client_socket: socket.socket, host: str) -> ssl.SSLSocket:
775
+ cert_path, key_path = self.certificate_authority.issue_server_cert(host)
776
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
777
+ context.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path))
778
+ context.set_alpn_protocols(["http/1.1"])
779
+ return context.wrap_socket(client_socket, server_side=True)
780
+
781
+ def _open_sync_upstream_tls_socket(self, host: str, port: int) -> ssl.SSLSocket:
782
+ raw_socket = socket.create_connection((host, port))
783
+ context = ssl.create_default_context()
784
+ return context.wrap_socket(raw_socket, server_hostname=host)
785
+
786
+ def _read_request_from_socket(self, reader: BufferedSocketReader) -> ParsedRequest:
787
+ head = reader.readuntil(b"\r\n\r\n")
788
+ lines = head[:-4].decode("iso-8859-1").split("\r\n")
789
+ headers = self._parse_headers(lines[1:])
790
+ body = self._read_body_from_socket(reader, headers)
791
+ return parse_request_text((head[:-4] + b"\r\n\r\n" + body).decode("iso-8859-1"))
792
+
793
+ def _read_response_from_socket(
794
+ self,
795
+ reader: BufferedSocketReader,
796
+ request: ParsedRequest | None = None,
797
+ ) -> ParsedResponse:
798
+ head = reader.readuntil(b"\r\n\r\n")
799
+ lines = head[:-4].decode("iso-8859-1").split("\r\n")
800
+ status_line = lines[0]
801
+ try:
802
+ version, status_code, reason = status_line.split(" ", 2)
803
+ except ValueError:
804
+ version, status_code = status_line.split(" ", 1)
805
+ reason = ""
806
+
807
+ headers = self._parse_headers(lines[1:])
808
+ parsed_status_code = int(status_code)
809
+ if self._response_has_body(parsed_status_code, headers, request):
810
+ body = self._read_body_from_socket(reader, headers, is_response=True)
811
+ else:
812
+ body = b""
813
+ return ParsedResponse(
814
+ version=version,
815
+ status_code=parsed_status_code,
816
+ reason=reason,
817
+ headers=headers,
818
+ body=body,
819
+ raw=head[:-4] + b"\r\n\r\n" + body,
820
+ )
821
+
822
+ def _read_body_from_socket(
823
+ self,
824
+ reader: BufferedSocketReader,
825
+ headers: HeaderList,
826
+ is_response: bool = False,
827
+ ) -> bytes:
828
+ header_map = {name.lower(): value for name, value in headers}
829
+ transfer_encoding = header_map.get("transfer-encoding", "").lower()
830
+
831
+ if "chunked" in transfer_encoding:
832
+ return self._read_chunked_body_from_socket(reader)
833
+
834
+ content_length = header_map.get("content-length")
835
+ if content_length is not None:
836
+ length = int(content_length)
837
+ if length == 0:
838
+ return b""
839
+ return reader.readexactly(length)
840
+
841
+ if is_response:
842
+ return reader.read()
843
+ return b""
844
+
845
+ def _read_chunked_body_from_socket(self, reader: BufferedSocketReader) -> bytes:
846
+ chunks: list[bytes] = []
847
+ while True:
848
+ line = reader.readuntil(b"\r\n")
849
+ chunks.append(line)
850
+ chunk_size = int(line.split(b";", 1)[0].strip(), 16)
851
+ if chunk_size == 0:
852
+ trailer = reader.readuntil(b"\r\n")
853
+ chunks.append(trailer)
854
+ break
855
+ chunk = reader.readexactly(chunk_size + 2)
856
+ chunks.append(chunk)
857
+ return b"".join(chunks)
858
+
859
+ def _relay_socket_bidirectional(self, client_socket: socket.socket, upstream_socket: socket.socket) -> None:
860
+ sockets = [client_socket, upstream_socket]
861
+ peer_map = {
862
+ client_socket.fileno(): upstream_socket,
863
+ upstream_socket.fileno(): client_socket,
864
+ }
865
+
866
+ while True:
867
+ readable, _, _ = select.select(sockets, [], [])
868
+ for current in readable:
869
+ chunk = current.recv(65536)
870
+ if not chunk:
871
+ return
872
+ peer_map[current.fileno()].sendall(chunk)
873
+
874
+ def _target_for_fixed_tunnel(self, request: ParsedRequest, fixed_target: UpstreamTarget) -> UpstreamTarget:
875
+ path = request.target or "/"
876
+ lowered_target = request.target.lower()
877
+ if lowered_target.startswith(("http://", "https://", "ws://", "wss://")):
878
+ path = self._resolve_target(request).path
879
+ return UpstreamTarget(host=fixed_target.host, port=fixed_target.port, path=path, tls=fixed_target.tls)
880
+
881
+ def _response_for_interception(self, response: ParsedResponse) -> ParsedResponse:
882
+ normalized_body, _, fully_decoded = normalize_http_body(response.headers, response.body)
883
+ filtered_headers = [
884
+ (name, value)
885
+ for name, value in response.headers
886
+ if name.lower() not in {"transfer-encoding", "content-encoding", "content-length"}
887
+ ]
888
+ body_changed = normalized_body != response.body
889
+ headers_changed = len(filtered_headers) != len(response.headers)
890
+ if not fully_decoded or (not body_changed and not headers_changed):
891
+ return response
892
+
893
+ editable = ParsedResponse(
894
+ version=response.version,
895
+ status_code=response.status_code,
896
+ reason=response.reason,
897
+ headers=filtered_headers,
898
+ body=normalized_body,
899
+ raw=b"",
900
+ )
901
+ editable.raw = render_response_bytes(editable)
902
+ return editable
903
+
904
+ def _record_request(self, entry, request: ParsedRequest, target: UpstreamTarget) -> None:
905
+ entry.request = RequestData(
906
+ method=request.method,
907
+ target=request.target,
908
+ version=request.version,
909
+ headers=list(request.headers),
910
+ body=request.body,
911
+ host=target.host,
912
+ port=target.port,
913
+ path=target.path,
914
+ )
915
+ entry.upstream_addr = f"{target.host}:{target.port}"
916
+ entry.state = "forwarding"
917
+
918
+ def _record_response(self, entry, response: ParsedResponse, target: UpstreamTarget) -> None:
919
+ visible_response = self._response_for_interception(response)
920
+ entry.response = ResponseData(
921
+ version=visible_response.version,
922
+ status_code=visible_response.status_code,
923
+ reason=visible_response.reason,
924
+ headers=list(visible_response.headers),
925
+ body=visible_response.body,
926
+ )
927
+ entry.upstream_addr = f"{target.host}:{target.port}"
928
+ entry.state = "complete"
929
+
930
+ def _record_error(self, entry, message: str) -> None:
931
+ entry.error = message
932
+ entry.state = "error"
933
+
934
+ def _mark_streaming(self, entry) -> None:
935
+ entry.state = "streaming"
936
+
937
+ def _mark_complete(self, entry) -> None:
938
+ if entry.state not in {"error", "dropped"}:
939
+ entry.state = "complete"
940
+
941
+ def _apply_match_replace_to_request(self, request: ParsedRequest) -> ParsedRequest:
942
+ updated = self._apply_match_replace_rules_to_text(render_request_text(request), "request")
943
+ if updated == render_request_text(request):
944
+ return request
945
+ return parse_request_text(updated)
946
+
947
+ def _build_local_response(self, request: ParsedRequest) -> ParsedResponse | None:
948
+ if request.method.upper() == "CONNECT":
949
+ return None
950
+ host = self._request_host(request)
951
+ if host not in LOCAL_PROXY_HOSTS and not self._is_proxy_self_host(host):
952
+ return None
953
+
954
+ path = self._request_path(request)
955
+ if path in {"", "/"}:
956
+ body = self._local_index_body().encode("utf-8")
957
+ return self._build_static_response(
958
+ status_code=200,
959
+ reason="OK",
960
+ headers=[
961
+ ("Content-Type", "text/html; charset=utf-8"),
962
+ ],
963
+ body=body,
964
+ )
965
+
966
+ if path in {"/cert", "/cert/", "/cert/hexproxy-ca.crt", "/hexproxy-ca.crt"}:
967
+ cert_path = self.certificate_authority.ensure_ready()
968
+ body = cert_path.read_bytes()
969
+ return self._build_static_response(
970
+ status_code=200,
971
+ reason="OK",
972
+ headers=[
973
+ ("Content-Type", "application/x-x509-ca-cert"),
974
+ ("Content-Disposition", 'attachment; filename="hexproxy-ca.crt"'),
975
+ ],
976
+ body=body,
977
+ )
978
+
979
+ body = b"HexProxy local resource not found.\n"
980
+ return self._build_static_response(
981
+ status_code=404,
982
+ reason="Not Found",
983
+ headers=[("Content-Type", "text/plain; charset=utf-8")],
984
+ body=body,
985
+ )
986
+
987
+ def _build_static_response(
988
+ self,
989
+ status_code: int,
990
+ reason: str,
991
+ headers: HeaderList,
992
+ body: bytes,
993
+ ) -> ParsedResponse:
994
+ response = ParsedResponse(
995
+ version="HTTP/1.1",
996
+ status_code=status_code,
997
+ reason=reason,
998
+ headers=list(headers),
999
+ body=body,
1000
+ raw=b"",
1001
+ )
1002
+ response.raw = render_response_bytes(response)
1003
+ return response
1004
+
1005
+ def _local_index_body(self) -> str:
1006
+ cert_path = self.certificate_authority.cert_path()
1007
+ return (
1008
+ "<!doctype html>"
1009
+ "<html><head><meta charset='utf-8'><title>HexProxy CA</title></head>"
1010
+ "<body>"
1011
+ "<h1>HexProxy Certificate Authority</h1>"
1012
+ "<p>Download and trust the local CA certificate to inspect HTTPS traffic.</p>"
1013
+ "<p><a id='cert-link' href='/cert'>Download hexproxy-ca.crt</a></p>"
1014
+ "<p id='cert-url'></p>"
1015
+ f"<p>Certificate path: {cert_path}</p>"
1016
+ "<script>"
1017
+ "const link = document.getElementById('cert-link');"
1018
+ "const urlText = document.getElementById('cert-url');"
1019
+ "const certUrl = new URL('/cert', window.location.href);"
1020
+ "link.href = certUrl.href;"
1021
+ "urlText.textContent = `Certificate URL: ${certUrl.href}`;"
1022
+ "</script>"
1023
+ "</body></html>"
1024
+ )
1025
+
1026
+ def _apply_match_replace_to_response(self, response: ParsedResponse) -> ParsedResponse:
1027
+ editable = self._response_for_interception(response)
1028
+ original_text = render_response_text(editable)
1029
+ updated = self._apply_match_replace_rules_to_text(original_text, "response")
1030
+ if updated == original_text:
1031
+ return response
1032
+ return parse_response_text(updated)
1033
+
1034
+ def _apply_match_replace_rules_to_text(self, text: str, scope: str) -> str:
1035
+ updated = text
1036
+ for rule in self.store.match_replace_rules():
1037
+ if not self._rule_applies(rule, scope):
1038
+ continue
1039
+ if rule.mode == "regex":
1040
+ updated = re.sub(rule.match, rule.replace, updated)
1041
+ continue
1042
+ updated = updated.replace(rule.match, rule.replace)
1043
+ return updated
1044
+
1045
+ @staticmethod
1046
+ def _rule_applies(rule: MatchReplaceRule, scope: str) -> bool:
1047
+ if not rule.enabled:
1048
+ return False
1049
+ return rule.scope in {scope, "both"}
1050
+
1051
+ async def _write_simple_response(
1052
+ self,
1053
+ writer: asyncio.StreamWriter,
1054
+ status_code: int,
1055
+ reason: str,
1056
+ body: bytes,
1057
+ ) -> None:
1058
+ response = (
1059
+ f"HTTP/1.1 {status_code} {reason}\r\n"
1060
+ f"Content-Length: {len(body)}\r\n"
1061
+ "Content-Type: text/plain; charset=utf-8\r\n"
1062
+ "Connection: close\r\n"
1063
+ "\r\n"
1064
+ ).encode("iso-8859-1") + body
1065
+ writer.write(response)
1066
+ await writer.drain()
1067
+
1068
+ async def _write_connect_established(self, writer: asyncio.StreamWriter) -> None:
1069
+ writer.write(b"HTTP/1.1 200 Connection Established\r\nConnection: keep-alive\r\n\r\n")
1070
+ await writer.drain()
1071
+
1072
+ @staticmethod
1073
+ def _parse_headers(raw_lines: Iterable[str]) -> HeaderList:
1074
+ headers: HeaderList = []
1075
+ for line in raw_lines:
1076
+ if not line:
1077
+ continue
1078
+ if ":" not in line:
1079
+ raise ValueError(f"invalid header line: {line!r}")
1080
+ name, value = line.split(":", 1)
1081
+ headers.append((name.strip(), value.lstrip()))
1082
+ return headers
1083
+
1084
+ @staticmethod
1085
+ def _find_header(headers: HeaderList, name: str) -> str | None:
1086
+ needle = name.lower()
1087
+ for header_name, header_value in headers:
1088
+ if header_name.lower() == needle:
1089
+ return header_value
1090
+ return None
1091
+
1092
+ @staticmethod
1093
+ def _origin_form(path: str, query: str) -> str:
1094
+ base = path or "/"
1095
+ if query:
1096
+ return f"{base}?{query}"
1097
+ return base
1098
+
1099
+ def _request_host(self, request: ParsedRequest) -> str:
1100
+ lowered_target = request.target.lower()
1101
+ if lowered_target.startswith(("http://", "https://", "ws://", "wss://")):
1102
+ parsed = urlsplit(request.target)
1103
+ return (parsed.hostname or "").lower()
1104
+
1105
+ host_header = self._find_header(request.headers, "Host") or ""
1106
+ if host_header.startswith("[") and "]" in host_header:
1107
+ return host_header[1 : host_header.index("]")].lower()
1108
+ if ":" in host_header:
1109
+ return host_header.rsplit(":", 1)[0].lower()
1110
+ return host_header.lower()
1111
+
1112
+ def _request_path(self, request: ParsedRequest) -> str:
1113
+ lowered_target = request.target.lower()
1114
+ if lowered_target.startswith(("http://", "https://", "ws://", "wss://")):
1115
+ parsed = urlsplit(request.target)
1116
+ return self._origin_form(parsed.path, parsed.query)
1117
+ return request.target or "/"
1118
+
1119
+ def _is_proxy_self_host(self, host: str) -> bool:
1120
+ if not host:
1121
+ return False
1122
+ normalized_host = host.lower()
1123
+ return normalized_host in {self.listen_host.lower(), "0.0.0.0"} or (
1124
+ self.listen_host in {"127.0.0.1", "0.0.0.0"} and normalized_host == "127.0.0.1"
1125
+ )
1126
+
1127
+ def _response_has_body(
1128
+ self,
1129
+ status_code: int,
1130
+ headers: HeaderList,
1131
+ request: ParsedRequest | None,
1132
+ ) -> bool:
1133
+ if request is not None and request.method.upper() == "HEAD":
1134
+ return False
1135
+ if 100 <= status_code < 200 or status_code in {101, 204, 304}:
1136
+ return False
1137
+ if self._find_header(headers, "Upgrade") is not None and status_code == 101:
1138
+ return False
1139
+ return True
1140
+
1141
+ def _is_websocket_upgrade(self, request: ParsedRequest, response: ParsedResponse) -> bool:
1142
+ if response.status_code != 101:
1143
+ return False
1144
+ request_upgrade = self._find_header(request.headers, "Upgrade")
1145
+ response_upgrade = self._find_header(response.headers, "Upgrade")
1146
+ if request_upgrade is None or response_upgrade is None:
1147
+ return False
1148
+ return request_upgrade.lower() == "websocket" and response_upgrade.lower() == "websocket"
1149
+
1150
+ def _is_websocket_request(self, request: ParsedRequest) -> bool:
1151
+ upgrade = self._find_header(request.headers, "Upgrade")
1152
+ if upgrade is None:
1153
+ return False
1154
+ return upgrade.lower() == "websocket"
1155
+
1156
+ def _describe_incomplete_read(self, exc: asyncio.IncompleteReadError) -> str:
1157
+ partial = bytes(exc.partial)
1158
+ if self._looks_like_tls_handshake(partial):
1159
+ return (
1160
+ "client started a TLS handshake directly. Configure the client to use HexProxy as an HTTP proxy, "
1161
+ "not an HTTPS proxy."
1162
+ )
1163
+ if partial.startswith(b"PRI * HTTP/2.0"):
1164
+ return "client started an HTTP/2 connection directly. HexProxy expects HTTP/1.1 proxy requests."
1165
+ if not partial:
1166
+ return "client closed the connection before sending a complete request"
1167
+ return f"incomplete read: expected {exc.expected}, received {len(partial)}"
1168
+
1169
+ @staticmethod
1170
+ def _looks_like_tls_handshake(partial: bytes) -> bool:
1171
+ return len(partial) >= 3 and partial[0] == 0x16 and partial[1] == 0x03
1172
+
1173
+ @staticmethod
1174
+ def _format_peer(peername) -> str:
1175
+ if not peername:
1176
+ return "-"
1177
+ host, port = peername[:2]
1178
+ return f"{host}:{port}"