impersonate-proxy 0.2.1__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -0,0 +1,735 @@
1
+ #!/usr/bin/env python3
2
+ """HTTP/HTTPS proxy that impersonates browser TLS fingerprints.
3
+
4
+ Uses curl_cffi to re-issue every request with a browser TLS fingerprint
5
+ (JA3/JA4), defeating CDN fingerprint-based blocking of non-browser clients.
6
+
7
+ Supports both plain HTTP proxy requests and HTTPS CONNECT tunnels via
8
+ MITM with an auto-generated CA certificate stored in --ca-dir
9
+ (default: ~/.config/impersonate-proxy).
10
+
11
+ Usage:
12
+ impersonate-proxy [--port PORT] [--host HOST] [--impersonate BROWSER]
13
+
14
+ # As an HTTP proxy for curl:
15
+ curl -x http://127.0.0.1:8899 https://example.com
16
+
17
+ # As an HTTP proxy for ffmpeg:
18
+ ffmpeg -http_proxy http://127.0.0.1:8899 -i https://stream.example.com/live.m3u8 output.mp4
19
+
20
+ Environment variables:
21
+ IMPERSONATE_PROXY_PORT Port to listen on (default: 8899)
22
+ IMPERSONATE_PROXY_HOST Host to bind to (default: 127.0.0.1)
23
+ IMPERSONATE_PROXY_IMPERSONATE Browser to impersonate (default: chrome)
24
+ """
25
+
26
+ import argparse
27
+ import contextlib
28
+ import datetime
29
+ import http.client
30
+ import ipaddress
31
+ import logging
32
+ import os
33
+ import queue
34
+ import select
35
+ import signal
36
+ import socket
37
+ import ssl
38
+ import sys
39
+ import tempfile
40
+ import threading
41
+ from collections import OrderedDict
42
+ from http.server import BaseHTTPRequestHandler, HTTPServer
43
+ from socketserver import ThreadingMixIn
44
+
45
+ from cryptography import x509
46
+ from cryptography.hazmat.primitives import hashes, serialization
47
+ from cryptography.hazmat.primitives.asymmetric import ec
48
+ from cryptography.x509.oid import NameOID
49
+ from curl_cffi import requests as cffi_requests
50
+
51
+ CHUNK_SIZE: int = 65536
52
+
53
+ _CA_KEY: ec.EllipticCurvePrivateKey | None = None
54
+ _CA_CERT: x509.Certificate | None = None
55
+ _LEAF_KEY: ec.EllipticCurvePrivateKey | None = None
56
+ _HOST_CERT_CACHE: OrderedDict[str, ssl.SSLContext] = OrderedDict()
57
+ _HOST_CERT_LOCK: threading.Lock = threading.Lock()
58
+ _HOST_CERT_MAX: int = 256
59
+ _SESSION_POOL_MAX: int = 32
60
+ _SESSION_POOL: queue.Queue[cffi_requests.Session] = queue.Queue(maxsize=_SESSION_POOL_MAX)
61
+ _IMPERSONATE: str = os.environ.get("IMPERSONATE_PROXY_IMPERSONATE", "chrome")
62
+ _ENRICH_HEADERS: bool = os.environ.get("IMPERSONATE_PROXY_ENRICH_HEADERS", "true").lower() not in ("false", "0", "no")
63
+ _DEBUG: bool = False
64
+ logger: logging.Logger = logging.getLogger("impersonate-proxy")
65
+
66
+ _SENSITIVE_HEADERS: set[str] = {
67
+ "authorization",
68
+ "proxy-authorization",
69
+ "cookie",
70
+ "set-cookie",
71
+ "token",
72
+ "x-api-key",
73
+ }
74
+
75
+
76
+ def _show_identifying(val: str) -> str:
77
+ """Return val if debug mode is active, otherwise '[redacted]'."""
78
+ return val if _DEBUG else "[redacted]"
79
+
80
+
81
+ def _sanitize_headers(headers: dict[str, str]) -> dict[str, str]:
82
+ """Return headers with sensitive headers redacted."""
83
+ sanitized = {}
84
+ for k, v in headers.items():
85
+ if k.lower() in _SENSITIVE_HEADERS:
86
+ sanitized[k] = "[redacted-sensitive]"
87
+ else:
88
+ sanitized[k] = v
89
+ return sanitized
90
+
91
+
92
+ def _init_ca(ca_dir: str | None = None) -> None:
93
+ """Load or generate self-signed CA files in ca_dir for MITM CONNECT handling."""
94
+ global _CA_KEY, _CA_CERT
95
+ with _HOST_CERT_LOCK:
96
+ _HOST_CERT_CACHE.clear()
97
+ _clear_session_pool()
98
+ if ca_dir is None:
99
+ ca_dir = os.environ.get("IMPERSONATE_PROXY_CA_DIR") or os.path.expanduser("~/.config/impersonate-proxy")
100
+
101
+ os.makedirs(ca_dir, exist_ok=True)
102
+
103
+ key_path = os.path.join(ca_dir, "ca.key")
104
+ cert_path = os.path.join(ca_dir, "ca.crt")
105
+
106
+ try:
107
+ # Try loading existing key and cert
108
+ if os.path.exists(key_path) and os.path.exists(cert_path):
109
+ with open(key_path, "rb") as f:
110
+ _CA_KEY = serialization.load_pem_private_key(f.read(), password=None) # type: ignore
111
+ with open(cert_path, "rb") as f:
112
+ _CA_CERT = x509.load_pem_x509_certificate(f.read())
113
+ logger.info(f"Loaded existing CA key and certificate from {_show_identifying(ca_dir)}")
114
+ return
115
+
116
+ # Generate new key and cert
117
+ _CA_KEY = ec.generate_private_key(ec.SECP256R1())
118
+ subject = issuer = x509.Name(
119
+ [
120
+ x509.NameAttribute(NameOID.COMMON_NAME, "Impersonate Proxy CA"),
121
+ ]
122
+ )
123
+ subject_key_id = x509.SubjectKeyIdentifier.from_public_key(_CA_KEY.public_key())
124
+ _CA_CERT = (
125
+ x509.CertificateBuilder()
126
+ .subject_name(subject)
127
+ .issuer_name(issuer)
128
+ .public_key(_CA_KEY.public_key())
129
+ .serial_number(x509.random_serial_number())
130
+ .not_valid_before(datetime.datetime.now(datetime.UTC))
131
+ .not_valid_after(datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365))
132
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
133
+ .add_extension(subject_key_id, critical=False)
134
+ .add_extension(
135
+ x509.KeyUsage(
136
+ digital_signature=True,
137
+ content_commitment=False,
138
+ key_encipherment=False,
139
+ data_encipherment=False,
140
+ key_agreement=False,
141
+ key_cert_sign=True,
142
+ crl_sign=True,
143
+ encipher_only=False,
144
+ decipher_only=False,
145
+ ),
146
+ critical=True,
147
+ )
148
+ .sign(_CA_KEY, hashes.SHA256())
149
+ )
150
+
151
+ # Save private key
152
+ key_bytes = _CA_KEY.private_bytes(
153
+ encoding=serialization.Encoding.PEM,
154
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
155
+ encryption_algorithm=serialization.NoEncryption(),
156
+ )
157
+ # Write key with owner-only permissions (mode 0o600)
158
+ fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
159
+ with os.fdopen(fd, "wb") as f:
160
+ f.write(key_bytes)
161
+
162
+ # Save certificate
163
+ cert_bytes = _CA_CERT.public_bytes(serialization.Encoding.PEM)
164
+ with open(cert_path, "wb") as f:
165
+ f.write(cert_bytes)
166
+
167
+ logger.info(f"Generated and saved new CA key and certificate in {_show_identifying(ca_dir)}")
168
+
169
+ except Exception as e:
170
+ logger.warning(f"MITM CA init failed ({e}) — CONNECT will fall back to raw tunnel")
171
+
172
+
173
+ def _get_cert_for_host(hostname: str) -> ssl.SSLContext:
174
+ """Get or create a cached SSL context for the given hostname."""
175
+ with _HOST_CERT_LOCK:
176
+ ctx = _HOST_CERT_CACHE.get(hostname)
177
+ if ctx is not None:
178
+ logger.debug(f"SSLContext cache hit for: {_show_identifying(hostname)}")
179
+ return ctx
180
+
181
+ try:
182
+ san = x509.IPAddress(ipaddress.ip_address(hostname))
183
+ except ValueError:
184
+ san = x509.DNSName(hostname)
185
+
186
+ global _LEAF_KEY
187
+ if _LEAF_KEY is None:
188
+ _LEAF_KEY = ec.generate_private_key(ec.SECP256R1())
189
+ key = _LEAF_KEY
190
+ logger.debug(f"Generating dynamic host certificate for: {_show_identifying(hostname)}")
191
+ cn = hostname[:64] if len(hostname) > 64 else hostname
192
+ subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
193
+ cert = (
194
+ x509.CertificateBuilder()
195
+ .subject_name(subject)
196
+ .issuer_name(_CA_CERT.subject) # type: ignore
197
+ .public_key(key.public_key())
198
+ .serial_number(x509.random_serial_number())
199
+ .not_valid_before(datetime.datetime.now(datetime.UTC))
200
+ .not_valid_after(datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=30))
201
+ .add_extension(
202
+ x509.SubjectAlternativeName([san]),
203
+ critical=False,
204
+ )
205
+ .add_extension(
206
+ x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
207
+ critical=False,
208
+ )
209
+ .add_extension(
210
+ x509.AuthorityKeyIdentifier.from_issuer_public_key(_CA_KEY.public_key()), # type: ignore
211
+ critical=False,
212
+ )
213
+ .add_extension(
214
+ x509.KeyUsage(
215
+ digital_signature=True,
216
+ content_commitment=False,
217
+ key_encipherment=True,
218
+ data_encipherment=False,
219
+ key_agreement=False,
220
+ key_cert_sign=False,
221
+ crl_sign=False,
222
+ encipher_only=False,
223
+ decipher_only=False,
224
+ ),
225
+ critical=True,
226
+ )
227
+ .add_extension(
228
+ x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),
229
+ critical=False,
230
+ )
231
+ .sign(_CA_KEY, hashes.SHA256()) # type: ignore
232
+ )
233
+
234
+ cert_pem = cert.public_bytes(serialization.Encoding.PEM)
235
+ key_pem = key.private_bytes(
236
+ serialization.Encoding.PEM,
237
+ serialization.PrivateFormat.TraditionalOpenSSL,
238
+ serialization.NoEncryption(),
239
+ )
240
+
241
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
242
+ cert_file = key_file = None
243
+ try:
244
+ with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as cf:
245
+ cf.write(cert_pem)
246
+ cert_file = cf.name
247
+ with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as kf:
248
+ kf.write(key_pem)
249
+ key_file = kf.name
250
+ ctx.load_cert_chain(cert_file, key_file)
251
+ finally:
252
+ if cert_file:
253
+ with contextlib.suppress(OSError):
254
+ os.unlink(cert_file)
255
+ if key_file:
256
+ with contextlib.suppress(OSError):
257
+ os.unlink(key_file)
258
+
259
+ with _HOST_CERT_LOCK:
260
+ # Double-check: another thread may have inserted while we generated
261
+ existing = _HOST_CERT_CACHE.get(hostname)
262
+ if existing is not None:
263
+ return existing
264
+ _HOST_CERT_CACHE[hostname] = ctx
265
+ while len(_HOST_CERT_CACHE) > _HOST_CERT_MAX:
266
+ evicted, _ = _HOST_CERT_CACHE.popitem(last=False)
267
+ logger.debug(f"Evicted host from certificate cache: {_show_identifying(evicted)}")
268
+ return ctx
269
+
270
+
271
+ def _clear_session_pool() -> None:
272
+ """Clear all sessions in the pool."""
273
+ while not _SESSION_POOL.empty():
274
+ try:
275
+ sess = _SESSION_POOL.get_nowait()
276
+ sess.close()
277
+ except queue.Empty:
278
+ break
279
+
280
+
281
+ def _get_session() -> cffi_requests.Session:
282
+ """Get a reused curl_cffi session from the pool or create a new one."""
283
+ try:
284
+ return _SESSION_POOL.get_nowait()
285
+ except queue.Empty:
286
+ return cffi_requests.Session(impersonate=_IMPERSONATE)
287
+
288
+
289
+ def _release_session(session: cffi_requests.Session, *, healthy: bool = True) -> None:
290
+ """Release a session back to the pool if healthy; otherwise close and discard."""
291
+ if healthy and not _SESSION_POOL.full():
292
+ _SESSION_POOL.put_nowait(session)
293
+ else:
294
+ with contextlib.suppress(Exception):
295
+ session.close()
296
+
297
+
298
+ def _enrich_headers(headers: dict[str, str], impersonate: str) -> dict[str, str]:
299
+ """Enrich headers to match the browser profile we are impersonating.
300
+
301
+ Guarantees standard clients (curl, requests, etc.) present correct browser headers,
302
+ preventing JA3/UA mismatch blocks.
303
+ """
304
+ enriched = dict(headers)
305
+ headers_lower = {k.lower(): (k, v) for k, v in enriched.items()}
306
+
307
+ # Check if incoming User-Agent is missing or from a standard non-browser library
308
+ ua = headers_lower.get("user-agent", ("", ""))[1]
309
+ is_non_browser = not ua or any(
310
+ bot in ua.lower()
311
+ for bot in ["curl", "python", "requests", "urllib", "wget", "httpclient", "go-http-client", "postman"]
312
+ )
313
+
314
+ if impersonate.startswith("firefox"):
315
+ if is_non_browser:
316
+ ua_key = "User-Agent"
317
+ if "user-agent" in headers_lower:
318
+ ua_key = headers_lower["user-agent"][0]
319
+ enriched[ua_key] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0"
320
+
321
+ firefox_defaults = {
322
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
323
+ "Accept-Language": "en-US,en;q=0.5",
324
+ "Upgrade-Insecure-Requests": "1",
325
+ "Sec-Fetch-Dest": "document",
326
+ "Sec-Fetch-Mode": "navigate",
327
+ "Sec-Fetch-Site": "none",
328
+ "Sec-Fetch-User": "?1",
329
+ }
330
+ for k, v in firefox_defaults.items():
331
+ if k.lower() not in headers_lower:
332
+ enriched[k] = v
333
+ else: # chrome/default
334
+ if is_non_browser:
335
+ ua_key = "User-Agent"
336
+ if "user-agent" in headers_lower:
337
+ ua_key = headers_lower["user-agent"][0]
338
+ enriched[ua_key] = (
339
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
340
+ )
341
+
342
+ chrome_defaults = {
343
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
344
+ "Accept-Language": "en-US,en;q=0.9",
345
+ "Upgrade-Insecure-Requests": "1",
346
+ "Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
347
+ "Sec-Ch-Ua-Mobile": "?0",
348
+ "Sec-Ch-Ua-Platform": '"Windows"',
349
+ "Sec-Fetch-Dest": "document",
350
+ "Sec-Fetch-Mode": "navigate",
351
+ "Sec-Fetch-Site": "none",
352
+ "Sec-Fetch-User": "?1",
353
+ }
354
+ for k, v in chrome_defaults.items():
355
+ if k.lower() not in headers_lower:
356
+ enriched[k] = v
357
+
358
+ return enriched
359
+
360
+
361
+ def _do_request(
362
+ method: str,
363
+ url: str,
364
+ headers: dict[str, str],
365
+ body: bytes | None,
366
+ allow_redirects: bool = False,
367
+ ) -> cffi_requests.Response | None:
368
+ """Issue a request via curl_cffi with TLS impersonation."""
369
+ session = _get_session()
370
+ try:
371
+ out_headers = _enrich_headers(headers, _IMPERSONATE) if _ENRICH_HEADERS else dict(headers)
372
+ logger.debug(
373
+ f"Issuing request: {method} {_show_identifying(url)} "
374
+ f"(enrich={_ENRICH_HEADERS}, headers={_sanitize_headers(out_headers)})"
375
+ )
376
+ resp = session.request(
377
+ method=method,
378
+ url=url,
379
+ headers=out_headers,
380
+ data=body,
381
+ timeout=(10, 300),
382
+ allow_redirects=allow_redirects,
383
+ stream=True,
384
+ )
385
+ orig_close = resp.close
386
+ session_released = False
387
+
388
+ def custom_close():
389
+ nonlocal session_released
390
+ orig_close()
391
+ if not session_released:
392
+ _release_session(session)
393
+ session_released = True
394
+
395
+ resp.close = custom_close
396
+ return resp
397
+ except Exception as e:
398
+ logger.error(f"Upstream request failed for {_show_identifying(url)}: {e}")
399
+ _release_session(session, healthy=False)
400
+ return None
401
+
402
+
403
+ def _raw_tunnel(client_sock: socket.socket, host: str, port: int) -> None:
404
+ """Relay bytes between client and upstream without inspection."""
405
+ logger.info(f"Establishing raw tunnel to: {_show_identifying(f'{host}:{port}')}")
406
+ try:
407
+ upstream = socket.create_connection((host, port), timeout=10)
408
+ except Exception as e:
409
+ logger.error(f"Raw tunnel connect failed for {_show_identifying(f'{host}:{port}')}: {e}")
410
+ return
411
+ try:
412
+ while True:
413
+ readable, _, _ = select.select([client_sock, upstream], [], [], 30)
414
+ if not readable:
415
+ break
416
+ for sock in readable:
417
+ data = sock.recv(CHUNK_SIZE)
418
+ if not data:
419
+ raise ConnectionError("closed")
420
+ if sock is client_sock:
421
+ upstream.sendall(data)
422
+ else:
423
+ client_sock.sendall(data)
424
+ except Exception:
425
+ pass
426
+ finally:
427
+ with contextlib.suppress(Exception):
428
+ upstream.shutdown(socket.SHUT_RDWR)
429
+ upstream.close()
430
+
431
+
432
+ class ProxyHandler(BaseHTTPRequestHandler):
433
+ def log_message(self, format, *args):
434
+ pass
435
+
436
+ def do_CONNECT(self) -> None:
437
+ host, _, port_str = self.path.rpartition(":")
438
+ host = host.strip("[]")
439
+ try:
440
+ port = int(port_str) if port_str else 443
441
+ except ValueError:
442
+ logger.warning(f"CONNECT bad host:port: {_show_identifying(self.path[:120])}")
443
+ self.send_error(400, "Bad host:port")
444
+ return
445
+
446
+ self.send_response(200, "Connection established")
447
+ self.end_headers()
448
+
449
+ if _CA_KEY is None:
450
+ logger.info(f"CONNECT {_show_identifying(f'{host}:{port}')} (raw tunnel, no impersonation)")
451
+ _raw_tunnel(self.connection, host, port)
452
+ self.close_connection = True
453
+ return
454
+
455
+ # MITM: wrap the client socket with TLS using a cached forged cert
456
+ try:
457
+ ctx = _get_cert_for_host(host)
458
+ client_tls = ctx.wrap_socket(self.connection, server_side=True)
459
+ except Exception as e:
460
+ logger.error(f"MITM TLS wrap error for {_show_identifying(host)}: {e}")
461
+ self.close_connection = True
462
+ return
463
+
464
+ # Read HTTP requests from the decrypted TLS stream and proxy via curl_cffi
465
+ rfile = wfile = None
466
+ try:
467
+ rfile = client_tls.makefile("rb")
468
+ wfile = client_tls.makefile("wb")
469
+
470
+ while True:
471
+ req_line = rfile.readline(8193)
472
+ if not req_line or req_line.strip() == b"":
473
+ break
474
+
475
+ parts = req_line.decode("latin-1").strip().split(" ", 2)
476
+ if len(parts) < 2:
477
+ break
478
+ method = parts[0]
479
+ path = parts[1]
480
+
481
+ # Read headers
482
+ headers = {}
483
+ while True:
484
+ hline = rfile.readline(8193)
485
+ if hline in (b"\r\n", b"\n", b""):
486
+ break
487
+ if b":" in hline:
488
+ k, v = hline.decode("latin-1").split(":", 1)
489
+ headers[k.strip()] = v.strip()
490
+
491
+ # Read body if present
492
+ body = None
493
+ cl = headers.get("Content-Length")
494
+ if cl:
495
+ try:
496
+ body = rfile.read(int(cl))
497
+ except ValueError:
498
+ logger.warning("CONNECT-MITM: invalid Content-Length header, ignoring body")
499
+
500
+ # Build full URL
501
+ scheme = "https"
502
+ if port == 443:
503
+ url = f"{scheme}://{host}{path}"
504
+ else:
505
+ url = f"{scheme}://{host}:{port}{path}"
506
+
507
+ # Filter hop-by-hop headers
508
+ skip = {
509
+ "host",
510
+ "proxy-connection",
511
+ "connection",
512
+ "keep-alive",
513
+ "transfer-encoding",
514
+ "te",
515
+ "trailer",
516
+ "upgrade",
517
+ "proxy-authorization",
518
+ "proxy-authenticate",
519
+ }
520
+ fwd_headers = {k: v for k, v in headers.items() if k.lower() not in skip}
521
+
522
+ logger.debug(
523
+ f"CONNECT-MITM proxying request: {method} {_show_identifying(url)} (headers={_sanitize_headers(fwd_headers)})"
524
+ )
525
+
526
+ r = _do_request(method, url, fwd_headers, body, allow_redirects=False)
527
+ if r is None:
528
+ wfile.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
529
+ wfile.flush()
530
+ break
531
+
532
+ try:
533
+ skip_h = {"transfer-encoding", "content-encoding", "content-length", "connection", "keep-alive"}
534
+ resp_headers = [(k, v) for k, v in r.headers.items() if k.lower() not in skip_h]
535
+ status_code = r.status_code
536
+ reason = http.client.responses.get(status_code, "Unknown")
537
+ wfile.write(f"HTTP/1.1 {status_code} {reason}\r\n".encode())
538
+ for k, v in resp_headers:
539
+ wfile.write(f"{k}: {v}\r\n".encode())
540
+ wfile.write(b"Transfer-Encoding: chunked\r\n")
541
+ wfile.write(b"Connection: close\r\n\r\n")
542
+ for chunk in r.iter_content(CHUNK_SIZE):
543
+ if chunk:
544
+ wfile.write(f"{len(chunk):x}\r\n".encode())
545
+ wfile.write(chunk)
546
+ wfile.write(b"\r\n")
547
+ wfile.write(b"0\r\n\r\n")
548
+ wfile.flush()
549
+ if status_code >= 400 or _DEBUG:
550
+ logger.info(f"CONNECT-MITM {method} {_show_identifying(url)} -> {status_code}")
551
+ finally:
552
+ r.close()
553
+ # Connection: close was sent — break so the TLS socket
554
+ # closes and the client sees EOF (end-of-body).
555
+ break
556
+
557
+ except Exception as e:
558
+ logger.error(f"MITM handler error: {e}")
559
+ finally:
560
+ if rfile:
561
+ with contextlib.suppress(Exception):
562
+ rfile.close()
563
+ if wfile:
564
+ with contextlib.suppress(Exception):
565
+ wfile.close()
566
+ with contextlib.suppress(Exception):
567
+ client_tls.shutdown(socket.SHUT_RDWR)
568
+ client_tls.close()
569
+
570
+ self.close_connection = True
571
+
572
+ def _proxy(self) -> None:
573
+ url = self.path
574
+ if not url.startswith("http"):
575
+ logger.warning(f"HTTP Proxy bad request: {_show_identifying(url)}")
576
+ self.send_error(400, "Absolute URL required")
577
+ return
578
+
579
+ skip = {
580
+ "host",
581
+ "proxy-connection",
582
+ "connection",
583
+ "keep-alive",
584
+ "transfer-encoding",
585
+ "te",
586
+ "trailer",
587
+ "upgrade",
588
+ "proxy-authorization",
589
+ "proxy-authenticate",
590
+ }
591
+ headers = {}
592
+ for key, val in self.headers.items():
593
+ if key.lower() not in skip:
594
+ headers[key] = val
595
+
596
+ body = None
597
+ content_length = self.headers.get("Content-Length")
598
+ if content_length:
599
+ body = self.rfile.read(int(content_length))
600
+
601
+ logger.debug(
602
+ f"HTTP Proxy proxying request: {self.command} {_show_identifying(url)} (headers={_sanitize_headers(headers)})"
603
+ )
604
+
605
+ resp = _do_request(self.command, url, headers, body)
606
+ if resp is None:
607
+ logger.error(f"HTTP Proxy upstream request failed for: {_show_identifying(url)}")
608
+ self.send_error(502, "Upstream request failed")
609
+ return
610
+
611
+ try:
612
+ is_head = self.command == "HEAD"
613
+ skip_resp = {"transfer-encoding", "content-encoding", "content-length"}
614
+ resp_headers = [(k, v or "") for k, v in resp.headers.items() if k.lower() not in skip_resp]
615
+ if is_head:
616
+ self.send_response(resp.status_code)
617
+ for key, val in resp_headers:
618
+ self.send_header(key, val)
619
+ cl = resp.headers.get("content-length")
620
+ if cl:
621
+ self.send_header("Content-Length", cl)
622
+ self.end_headers()
623
+ else:
624
+ self.send_response(resp.status_code)
625
+ for key, val in resp_headers:
626
+ self.send_header(key, val)
627
+ self.send_header("Transfer-Encoding", "chunked")
628
+ self.send_header("Connection", "close")
629
+ self.end_headers()
630
+ for chunk in resp.iter_content(CHUNK_SIZE):
631
+ if chunk:
632
+ self.wfile.write(f"{len(chunk):x}\r\n".encode())
633
+ self.wfile.write(chunk)
634
+ self.wfile.write(b"\r\n")
635
+ self.wfile.write(b"0\r\n\r\n")
636
+ self.wfile.flush()
637
+ logger.info(f"HTTP Proxy {self.command} {_show_identifying(url)} -> {resp.status_code}")
638
+ finally:
639
+ resp.close()
640
+
641
+ do_GET = _proxy
642
+ do_POST = _proxy
643
+ do_PUT = _proxy
644
+ do_HEAD = _proxy
645
+ do_OPTIONS = _proxy
646
+
647
+
648
+ def run(
649
+ host: str = "127.0.0.1",
650
+ port: int = 8899,
651
+ impersonate: str = "chrome",
652
+ ca_dir: str | None = None,
653
+ enrich_headers: bool = True,
654
+ debug: bool = False,
655
+ ) -> None:
656
+ global _IMPERSONATE, _ENRICH_HEADERS, _DEBUG
657
+ _IMPERSONATE = impersonate
658
+ _ENRICH_HEADERS = enrich_headers
659
+ _DEBUG = debug
660
+
661
+ logging.basicConfig(
662
+ level=logging.DEBUG if debug else logging.INFO,
663
+ format="%(asctime)s [%(levelname)s] %(message)s",
664
+ datefmt="%Y-%m-%d %H:%M:%S",
665
+ handlers=[logging.StreamHandler(sys.stdout)],
666
+ force=True, # Overwrites default pytest handler config in tests
667
+ )
668
+
669
+ _init_ca(ca_dir)
670
+
671
+ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
672
+ daemon_threads = True
673
+
674
+ server = ThreadingHTTPServer((host, port), ProxyHandler)
675
+ logger.info(
676
+ f"impersonate-proxy listening on {host}:{port} "
677
+ f"(impersonating {impersonate}, enrich_headers={enrich_headers}, debug={debug})"
678
+ )
679
+ server.serve_forever()
680
+
681
+
682
+ def main() -> None:
683
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
684
+ parser = argparse.ArgumentParser(description="HTTP/HTTPS proxy that impersonates browser TLS fingerprints")
685
+ parser.add_argument(
686
+ "--port",
687
+ "-p",
688
+ type=int,
689
+ default=int(os.environ.get("IMPERSONATE_PROXY_PORT", "8899")),
690
+ help="Port to listen on (default: 8899)",
691
+ )
692
+ parser.add_argument(
693
+ "--host",
694
+ "-H",
695
+ default=os.environ.get("IMPERSONATE_PROXY_HOST", "127.0.0.1"),
696
+ help="Host to bind to (default: 127.0.0.1)",
697
+ )
698
+ parser.add_argument(
699
+ "--impersonate",
700
+ "-i",
701
+ default=os.environ.get("IMPERSONATE_PROXY_IMPERSONATE", "chrome"),
702
+ help="Browser to impersonate (default: chrome)",
703
+ )
704
+ parser.add_argument(
705
+ "--ca-dir",
706
+ "-c",
707
+ default=os.environ.get("IMPERSONATE_PROXY_CA_DIR"),
708
+ help="Directory to store/load CA certificate and private key",
709
+ )
710
+ parser.add_argument(
711
+ "--no-enrich-headers",
712
+ action="store_true",
713
+ default=os.environ.get("IMPERSONATE_PROXY_ENRICH_HEADERS", "true").lower() in ("false", "0", "no"),
714
+ help="Disable automatic browser header enrichment (User-Agent, Sec-Fetch-*, etc.)",
715
+ )
716
+ parser.add_argument(
717
+ "--debug",
718
+ "-d",
719
+ action="store_true",
720
+ default=os.environ.get("IMPERSONATE_PROXY_DEBUG", "").lower() in ("true", "1", "yes"),
721
+ help="Enable verbose debug logging and show identifying details in logs",
722
+ )
723
+ args = parser.parse_args()
724
+ run(
725
+ host=args.host,
726
+ port=args.port,
727
+ impersonate=args.impersonate,
728
+ ca_dir=args.ca_dir,
729
+ enrich_headers=not args.no_enrich_headers,
730
+ debug=args.debug,
731
+ )
732
+
733
+
734
+ if __name__ == "__main__":
735
+ main()
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: impersonate-proxy
3
+ Version: 0.2.1
4
+ Summary: HTTP/HTTPS proxy that impersonates browser TLS fingerprints using curl_cffi
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ License-File: LICENSE
8
+ Requires-Dist: curl_cffi>=0.7.0
9
+ Requires-Dist: cryptography>=42.0.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: basedpyright>=1.20.0; extra == "dev"
12
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
13
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
14
+ Requires-Dist: requests>=2.28.0; extra == "dev"
15
+ Dynamic: license-file
@@ -0,0 +1,8 @@
1
+ impersonate_proxy/__init__.py,sha256=HfjVOrpTnmZ-xVFCYSVmX50EXaBQeJteUHG-PD6iQs8,22
2
+ impersonate_proxy/main.py,sha256=FSZXTjQH2A8N0CCVJ3yTZyc3miXVruFNYkqQ2_WfXTQ,27010
3
+ impersonate_proxy-0.2.1.dist-info/licenses/LICENSE,sha256=RDWK5fNnlTqvYIKmwApEgfeKNa7LKIz4xI8ofB9topg,1073
4
+ impersonate_proxy-0.2.1.dist-info/METADATA,sha256=60G51SfiSOP543Ox70AOVN6ySzzzuwi2q9mndcmBH1c,514
5
+ impersonate_proxy-0.2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
6
+ impersonate_proxy-0.2.1.dist-info/entry_points.txt,sha256=4yq6Vje_9hOjxLbBbqdEJKC_gJx2SfvSz8IcFyj7J0s,66
7
+ impersonate_proxy-0.2.1.dist-info/top_level.txt,sha256=lGeOcVKwGUJuJMvBQW_qcvlzMn3kRzUKIqFuPPlZcAM,18
8
+ impersonate_proxy-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ impersonate-proxy = impersonate_proxy.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicholas de Jong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ impersonate_proxy