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.
- impersonate_proxy/__init__.py +1 -0
- impersonate_proxy/main.py +735 -0
- impersonate_proxy-0.2.1.dist-info/METADATA +15 -0
- impersonate_proxy-0.2.1.dist-info/RECORD +8 -0
- impersonate_proxy-0.2.1.dist-info/WHEEL +5 -0
- impersonate_proxy-0.2.1.dist-info/entry_points.txt +2 -0
- impersonate_proxy-0.2.1.dist-info/licenses/LICENSE +21 -0
- impersonate_proxy-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -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,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
|