ja4proxy 0.1.0__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.
- ja4proxy/__init__.py +9 -0
- ja4proxy/certs.py +180 -0
- ja4proxy/cli.py +154 -0
- ja4proxy/engine/bridge.py +192 -0
- ja4proxy/engine/loader.py +162 -0
- ja4proxy/parser.py +138 -0
- ja4proxy/profiles.py +184 -0
- ja4proxy/server.py +683 -0
- ja4proxy-0.1.0.dist-info/METADATA +688 -0
- ja4proxy-0.1.0.dist-info/RECORD +14 -0
- ja4proxy-0.1.0.dist-info/WHEEL +5 -0
- ja4proxy-0.1.0.dist-info/entry_points.txt +2 -0
- ja4proxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- ja4proxy-0.1.0.dist-info/top_level.txt +1 -0
ja4proxy/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""ja4proxy: High-performance universal proxy server with browser TLS (JA4) and HTTP/2 impersonation."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
__author__ = "cipherwire"
|
|
5
|
+
|
|
6
|
+
from ja4proxy.engine.bridge import NativeEngine
|
|
7
|
+
from ja4proxy.server import ProxyServer
|
|
8
|
+
|
|
9
|
+
__all__ = ["ProxyServer", "NativeEngine", "__version__"]
|
ja4proxy/certs.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import ipaddress
|
|
3
|
+
import os
|
|
4
|
+
import ssl
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from cryptography import x509
|
|
8
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
10
|
+
from cryptography.x509.oid import NameOID
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _set_private_file_permissions(path: str) -> None:
|
|
14
|
+
"""Restricts file permissions to 0600 on POSIX platforms."""
|
|
15
|
+
if os.name != "nt":
|
|
16
|
+
try:
|
|
17
|
+
os.chmod(path, 0o600)
|
|
18
|
+
except OSError:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CertificateAuthority:
|
|
23
|
+
"""Manages the local root CA and signs dynamic leaf certificates for HTTPS interception."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, cert_dir: str | None = None):
|
|
26
|
+
if cert_dir:
|
|
27
|
+
self.cert_dir = cert_dir
|
|
28
|
+
else:
|
|
29
|
+
self.cert_dir = os.path.join(os.path.expanduser("~"), ".ja4proxy")
|
|
30
|
+
os.makedirs(self.cert_dir, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
self.ca_cert_path = os.path.join(self.cert_dir, "ca.pem")
|
|
33
|
+
self.ca_key_path = os.path.join(self.cert_dir, "ca.key")
|
|
34
|
+
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self._leaf_cache: dict[str, ssl.SSLContext] = {}
|
|
37
|
+
|
|
38
|
+
self._ensure_ca()
|
|
39
|
+
|
|
40
|
+
def _ensure_ca(self) -> None:
|
|
41
|
+
"""Loads existing CA certificate/key or generates a fresh 10-year root CA."""
|
|
42
|
+
if os.path.exists(self.ca_cert_path) and os.path.exists(self.ca_key_path):
|
|
43
|
+
with open(self.ca_key_path, "rb") as f:
|
|
44
|
+
self.ca_key = serialization.load_pem_private_key(
|
|
45
|
+
f.read(), password=None
|
|
46
|
+
)
|
|
47
|
+
with open(self.ca_cert_path, "rb") as f:
|
|
48
|
+
self.ca_cert = x509.load_pem_x509_certificate(f.read())
|
|
49
|
+
_set_private_file_permissions(self.ca_key_path)
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
# Generate fresh 2048-bit RSA key for CA
|
|
53
|
+
self.ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
54
|
+
ca_name = x509.Name(
|
|
55
|
+
[
|
|
56
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
57
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "ja4proxy Development"),
|
|
58
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "ja4proxy Root CA"),
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
63
|
+
self.ca_cert = (
|
|
64
|
+
x509.CertificateBuilder()
|
|
65
|
+
.subject_name(ca_name)
|
|
66
|
+
.issuer_name(ca_name)
|
|
67
|
+
.public_key(self.ca_key.public_key())
|
|
68
|
+
.serial_number(x509.random_serial_number())
|
|
69
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
70
|
+
.not_valid_after(now + datetime.timedelta(days=3650))
|
|
71
|
+
.add_extension(
|
|
72
|
+
x509.BasicConstraints(ca=True, path_length=None), critical=True
|
|
73
|
+
)
|
|
74
|
+
.add_extension(
|
|
75
|
+
x509.KeyUsage(
|
|
76
|
+
digital_signature=True,
|
|
77
|
+
content_commitment=False,
|
|
78
|
+
key_encipherment=False,
|
|
79
|
+
data_encipherment=False,
|
|
80
|
+
key_agreement=False,
|
|
81
|
+
key_cert_sign=True,
|
|
82
|
+
crl_sign=True,
|
|
83
|
+
encipher_only=False,
|
|
84
|
+
decipher_only=False,
|
|
85
|
+
),
|
|
86
|
+
critical=True,
|
|
87
|
+
)
|
|
88
|
+
.sign(self.ca_key, hashes.SHA256())
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
with open(self.ca_key_path, "wb") as f:
|
|
92
|
+
f.write(
|
|
93
|
+
self.ca_key.private_bytes(
|
|
94
|
+
encoding=serialization.Encoding.PEM,
|
|
95
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
96
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
_set_private_file_permissions(self.ca_key_path)
|
|
100
|
+
|
|
101
|
+
with open(self.ca_cert_path, "wb") as f:
|
|
102
|
+
f.write(self.ca_cert.public_bytes(serialization.Encoding.PEM))
|
|
103
|
+
|
|
104
|
+
def get_ssl_context_for_host(self, host: str) -> ssl.SSLContext:
|
|
105
|
+
"""
|
|
106
|
+
Dynamically generates or retrieves a cached SSLContext for the target hostname,
|
|
107
|
+
signed by this CA with appropriate Subject Alternative Names (SAN).
|
|
108
|
+
"""
|
|
109
|
+
h = host.strip()
|
|
110
|
+
if h.startswith("["):
|
|
111
|
+
end = h.find("]")
|
|
112
|
+
clean_host = h[1:end] if end != -1 else h
|
|
113
|
+
elif ":" in h:
|
|
114
|
+
parts = h.split(":")
|
|
115
|
+
clean_host = parts[0] if len(parts) == 2 else h
|
|
116
|
+
else:
|
|
117
|
+
clean_host = h
|
|
118
|
+
|
|
119
|
+
with self._lock:
|
|
120
|
+
if clean_host in self._leaf_cache:
|
|
121
|
+
return self._leaf_cache[clean_host]
|
|
122
|
+
|
|
123
|
+
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
124
|
+
|
|
125
|
+
subject_name = x509.Name(
|
|
126
|
+
[
|
|
127
|
+
x509.NameAttribute(NameOID.COMMON_NAME, clean_host),
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
ip_addr = ipaddress.ip_address(clean_host)
|
|
133
|
+
san = x509.SubjectAlternativeName([x509.IPAddress(ip_addr)])
|
|
134
|
+
except ValueError:
|
|
135
|
+
san = x509.SubjectAlternativeName([x509.DNSName(clean_host)])
|
|
136
|
+
|
|
137
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
138
|
+
leaf_cert = (
|
|
139
|
+
x509.CertificateBuilder()
|
|
140
|
+
.subject_name(subject_name)
|
|
141
|
+
.issuer_name(self.ca_cert.subject)
|
|
142
|
+
.public_key(leaf_key.public_key())
|
|
143
|
+
.serial_number(x509.random_serial_number())
|
|
144
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
145
|
+
.not_valid_after(now + datetime.timedelta(days=365))
|
|
146
|
+
.add_extension(
|
|
147
|
+
x509.BasicConstraints(ca=False, path_length=None), critical=True
|
|
148
|
+
)
|
|
149
|
+
.add_extension(san, critical=False)
|
|
150
|
+
.sign(self.ca_key, hashes.SHA256())
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
cert_pem = leaf_cert.public_bytes(serialization.Encoding.PEM)
|
|
154
|
+
key_pem = leaf_key.private_bytes(
|
|
155
|
+
encoding=serialization.Encoding.PEM,
|
|
156
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
157
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
161
|
+
|
|
162
|
+
leaf_cert_path = os.path.join(self.cert_dir, f"_leaf_{clean_host}.pem")
|
|
163
|
+
leaf_key_path = os.path.join(self.cert_dir, f"_leaf_{clean_host}.key")
|
|
164
|
+
with open(leaf_cert_path, "wb") as f:
|
|
165
|
+
f.write(cert_pem)
|
|
166
|
+
with open(leaf_key_path, "wb") as f:
|
|
167
|
+
f.write(key_pem)
|
|
168
|
+
_set_private_file_permissions(leaf_key_path)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
ctx.load_cert_chain(certfile=leaf_cert_path, keyfile=leaf_key_path)
|
|
172
|
+
finally:
|
|
173
|
+
try:
|
|
174
|
+
os.remove(leaf_cert_path)
|
|
175
|
+
os.remove(leaf_key_path)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
self._leaf_cache[clean_host] = ctx
|
|
180
|
+
return ctx
|
ja4proxy/cli.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ja4proxy import __version__
|
|
10
|
+
from ja4proxy.certs import CertificateAuthority
|
|
11
|
+
from ja4proxy.engine.bridge import NativeEngine
|
|
12
|
+
from ja4proxy.profiles import SUPPORTED_PROFILES, normalize_profile
|
|
13
|
+
from ja4proxy.server import ProxyServer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
@click.version_option(__version__, prog_name="ja4proxy")
|
|
18
|
+
def main() -> None:
|
|
19
|
+
"""ja4proxy: High-performance universal proxy server with browser TLS (JA4) and HTTP/2 impersonation."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@main.command()
|
|
24
|
+
@click.option("--host", default="127.0.0.1", help="Binding IP address.")
|
|
25
|
+
@click.option("--port", "-p", default=8888, type=int, help="Port to listen on.")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--profile",
|
|
28
|
+
default="chrome_133",
|
|
29
|
+
help="Default browser profile (e.g. chrome_133, safari_18_0, firefox_133).",
|
|
30
|
+
)
|
|
31
|
+
@click.option(
|
|
32
|
+
"--upstream-proxy",
|
|
33
|
+
default=None,
|
|
34
|
+
help="Default upstream proxy URL (e.g. socks5://user:pass@host:1080).",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--cert-dir", default=None, help="Directory to store CA and leaf certificates."
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--allow-private-networks",
|
|
41
|
+
is_flag=True,
|
|
42
|
+
help="Allow forwarding requests to private and loopback IP ranges.",
|
|
43
|
+
)
|
|
44
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose debug logging.")
|
|
45
|
+
def start(
|
|
46
|
+
host: str,
|
|
47
|
+
port: int,
|
|
48
|
+
profile: str,
|
|
49
|
+
upstream_proxy: str | None,
|
|
50
|
+
cert_dir: str | None,
|
|
51
|
+
allow_private_networks: bool,
|
|
52
|
+
verbose: bool,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Starts the ja4proxy server."""
|
|
55
|
+
log_level = logging.DEBUG if verbose else logging.INFO
|
|
56
|
+
logging.basicConfig(
|
|
57
|
+
level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
prof = normalize_profile(profile)
|
|
61
|
+
server = ProxyServer(
|
|
62
|
+
host=host,
|
|
63
|
+
port=port,
|
|
64
|
+
default_profile=prof,
|
|
65
|
+
cert_dir=cert_dir,
|
|
66
|
+
allow_private_networks=allow_private_networks,
|
|
67
|
+
upstream_proxy=upstream_proxy,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
ca_path = server.ca.ca_cert_path
|
|
71
|
+
click.echo(
|
|
72
|
+
f"[*] ja4proxy v{__version__} starting on http://{host}:{port}"
|
|
73
|
+
)
|
|
74
|
+
click.echo(f"[*] Default Profile: {prof}")
|
|
75
|
+
click.echo(f"[*] Protocols Supported: HTTP CONNECT, HTTP Gateway, SOCKS5 (Auto-detect)")
|
|
76
|
+
click.echo(f"[*] CA Certificate: {ca_path}")
|
|
77
|
+
click.echo(
|
|
78
|
+
"[*] Ready for HTTP and SOCKS5 clients (Python requests, Node axios, cURL, etc.)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
asyncio.run(server.serve_forever())
|
|
83
|
+
except KeyboardInterrupt:
|
|
84
|
+
click.echo("\n[*] Shutting down ja4proxy...")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@main.command(name="profiles")
|
|
88
|
+
def list_profiles() -> None:
|
|
89
|
+
"""Lists all supported browser profiles for TLS and HTTP/2 emulation."""
|
|
90
|
+
click.echo("Supported Browser Profiles:")
|
|
91
|
+
for prof in SUPPORTED_PROFILES:
|
|
92
|
+
click.echo(f" - {prof}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@main.command(name="ca")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--export",
|
|
98
|
+
"-e",
|
|
99
|
+
"export_path",
|
|
100
|
+
default=None,
|
|
101
|
+
help="Export root CA certificate (ca.pem) to specified path.",
|
|
102
|
+
)
|
|
103
|
+
def manage_ca(export_path: str | None) -> None:
|
|
104
|
+
"""Inspects or exports the local Root CA certificate."""
|
|
105
|
+
ca = CertificateAuthority()
|
|
106
|
+
click.echo(f"Root CA Certificate: {ca.ca_cert_path}")
|
|
107
|
+
click.echo(f"Root CA Private Key: {ca.ca_key_path}")
|
|
108
|
+
|
|
109
|
+
if export_path:
|
|
110
|
+
dest = os.path.abspath(export_path)
|
|
111
|
+
shutil.copyfile(ca.ca_cert_path, dest)
|
|
112
|
+
click.echo(f"Exported CA to: {dest}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@main.command(name="test")
|
|
116
|
+
@click.option(
|
|
117
|
+
"--profile",
|
|
118
|
+
default="chrome_133",
|
|
119
|
+
help="Browser profile to test against tls.peet.ws.",
|
|
120
|
+
)
|
|
121
|
+
def test_handshake(profile: str) -> None:
|
|
122
|
+
"""Performs a direct live handshake against tls.peet.ws to inspect upstream JA4."""
|
|
123
|
+
import json
|
|
124
|
+
|
|
125
|
+
prof = normalize_profile(profile)
|
|
126
|
+
click.echo(f"Testing upstream handshake for profile: {prof}...")
|
|
127
|
+
|
|
128
|
+
async def _test():
|
|
129
|
+
engine = NativeEngine.get_instance()
|
|
130
|
+
res = await engine.request(
|
|
131
|
+
method="GET",
|
|
132
|
+
url="https://tls.peet.ws/api/all",
|
|
133
|
+
profile=prof,
|
|
134
|
+
timeout_seconds=15,
|
|
135
|
+
)
|
|
136
|
+
return res
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
res = asyncio.run(_test())
|
|
140
|
+
click.echo(f"Status Code: {res['status']}")
|
|
141
|
+
click.echo(f"Negotiated Protocol: {res['protocol']}")
|
|
142
|
+
|
|
143
|
+
body = json.loads(res["body"].decode("utf-8", errors="replace"))
|
|
144
|
+
tls_info = body.get("tls", {})
|
|
145
|
+
click.echo(f"Remote Peer JA4: {tls_info.get('ja4')}")
|
|
146
|
+
click.echo(f"Remote Peer JA3: {tls_info.get('ja3')}")
|
|
147
|
+
click.echo(f"HTTP Version: {body.get('http_version')}")
|
|
148
|
+
except Exception as e:
|
|
149
|
+
click.echo(f"Error executing test: {e}", err=True)
|
|
150
|
+
sys.exit(1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
main()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import ctypes
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ja4proxy.engine.loader import resolve_native_library
|
|
9
|
+
from ja4proxy.profiles import get_default_headers, normalize_profile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EngineError(Exception):
|
|
13
|
+
"""Raised when the native uTLS engine fails to execute a request."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NativeEngine:
|
|
17
|
+
"""In-process bridge to the Go uTLS / fhttp shared library via ctypes."""
|
|
18
|
+
|
|
19
|
+
_instance: "NativeEngine | None" = None
|
|
20
|
+
_init_lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
def __init__(self, lib_path: str | None = None):
|
|
23
|
+
path = lib_path or resolve_native_library()
|
|
24
|
+
self.lib = ctypes.cdll.LoadLibrary(path)
|
|
25
|
+
|
|
26
|
+
# Set up C function signatures
|
|
27
|
+
self.lib.request.argtypes = [ctypes.c_char_p]
|
|
28
|
+
self.lib.request.restype = ctypes.c_char_p
|
|
29
|
+
|
|
30
|
+
self.lib.freeMemory.argtypes = [ctypes.c_char_p]
|
|
31
|
+
self.lib.freeMemory.restype = None
|
|
32
|
+
|
|
33
|
+
self.lib.destroySession.argtypes = [ctypes.c_char_p]
|
|
34
|
+
self.lib.destroySession.restype = ctypes.c_char_p
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def get_instance(cls) -> "NativeEngine":
|
|
38
|
+
if cls._instance is None:
|
|
39
|
+
with cls._init_lock:
|
|
40
|
+
if cls._instance is None:
|
|
41
|
+
cls._instance = cls()
|
|
42
|
+
return cls._instance
|
|
43
|
+
|
|
44
|
+
def _execute_sync(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
"""Synchronously invokes the C function and frees the Go-allocated memory."""
|
|
46
|
+
payload_bytes = json.dumps(payload).encode("utf-8")
|
|
47
|
+
resp_ptr = self.lib.request(payload_bytes)
|
|
48
|
+
|
|
49
|
+
if not resp_ptr:
|
|
50
|
+
raise EngineError("Native engine returned null pointer")
|
|
51
|
+
|
|
52
|
+
resp_id = None
|
|
53
|
+
try:
|
|
54
|
+
raw_bytes = ctypes.string_at(resp_ptr)
|
|
55
|
+
decoded = raw_bytes.decode("utf-8", errors="replace")
|
|
56
|
+
resp_obj = json.loads(decoded)
|
|
57
|
+
if isinstance(resp_obj, dict):
|
|
58
|
+
resp_id = resp_obj.get("id")
|
|
59
|
+
return resp_obj
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise EngineError(f"Failed to parse native engine response: {e}") from e
|
|
62
|
+
finally:
|
|
63
|
+
if resp_id:
|
|
64
|
+
try:
|
|
65
|
+
self.lib.freeMemory(resp_id.encode("utf-8"))
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
async def execute_async(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""Asynchronously executes request in threadpool, releasing Python GIL."""
|
|
71
|
+
loop = asyncio.get_running_loop()
|
|
72
|
+
return await loop.run_in_executor(None, self._execute_sync, payload)
|
|
73
|
+
|
|
74
|
+
def destroy_session_sync(self, session_id: str) -> bool:
|
|
75
|
+
"""Destroys an allocated session in the Go engine to release memory and cookies."""
|
|
76
|
+
payload = json.dumps({"sessionId": session_id}).encode("utf-8")
|
|
77
|
+
resp_ptr = self.lib.destroySession(payload)
|
|
78
|
+
if not resp_ptr:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
resp_id = None
|
|
82
|
+
try:
|
|
83
|
+
raw_bytes = ctypes.string_at(resp_ptr)
|
|
84
|
+
resp_obj = json.loads(raw_bytes.decode("utf-8", errors="replace"))
|
|
85
|
+
if isinstance(resp_obj, dict):
|
|
86
|
+
resp_id = resp_obj.get("id")
|
|
87
|
+
return bool(resp_obj.get("success", False))
|
|
88
|
+
return False
|
|
89
|
+
except Exception:
|
|
90
|
+
return False
|
|
91
|
+
finally:
|
|
92
|
+
if resp_id:
|
|
93
|
+
try:
|
|
94
|
+
self.lib.freeMemory(resp_id.encode("utf-8"))
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
async def destroy_session(self, session_id: str) -> bool:
|
|
99
|
+
loop = asyncio.get_running_loop()
|
|
100
|
+
return await loop.run_in_executor(None, self.destroy_session_sync, session_id)
|
|
101
|
+
|
|
102
|
+
async def request(
|
|
103
|
+
self,
|
|
104
|
+
method: str,
|
|
105
|
+
url: str,
|
|
106
|
+
headers: dict[str, str] | None = None,
|
|
107
|
+
body: bytes | None = None,
|
|
108
|
+
profile: str = "chrome_133",
|
|
109
|
+
proxy_url: str = "",
|
|
110
|
+
timeout_seconds: int = 30,
|
|
111
|
+
follow_redirects: bool = False,
|
|
112
|
+
insecure_skip_verify: bool = False,
|
|
113
|
+
session_id: str = "ja4_default",
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""
|
|
116
|
+
Dispatches an HTTP request through the native uTLS engine.
|
|
117
|
+
Merges canonical browser headers to ensure the target never sees automated client signatures.
|
|
118
|
+
"""
|
|
119
|
+
prof = normalize_profile(profile)
|
|
120
|
+
is_byte_req = False
|
|
121
|
+
encoded_body = ""
|
|
122
|
+
if body:
|
|
123
|
+
encoded_body = base64.b64encode(body).decode("utf-8")
|
|
124
|
+
is_byte_req = True
|
|
125
|
+
|
|
126
|
+
# Start with default browser headers for the profile
|
|
127
|
+
merged_headers: dict[str, str] = get_default_headers(prof)
|
|
128
|
+
|
|
129
|
+
# Overlay client headers (case-insensitive merge)
|
|
130
|
+
if headers:
|
|
131
|
+
for k, v in headers.items():
|
|
132
|
+
if v is not None:
|
|
133
|
+
merged_headers[k.lower()] = str(v)
|
|
134
|
+
|
|
135
|
+
payload = {
|
|
136
|
+
"sessionId": session_id,
|
|
137
|
+
"tlsClientIdentifier": prof,
|
|
138
|
+
"requestMethod": method.upper(),
|
|
139
|
+
"requestUrl": url,
|
|
140
|
+
"requestBody": encoded_body,
|
|
141
|
+
"isByteRequest": is_byte_req,
|
|
142
|
+
"isByteResponse": True,
|
|
143
|
+
"headers": merged_headers,
|
|
144
|
+
"requestCookies": [],
|
|
145
|
+
"proxyUrl": proxy_url,
|
|
146
|
+
"timeoutSeconds": timeout_seconds,
|
|
147
|
+
"followRedirects": follow_redirects,
|
|
148
|
+
"insecureSkipVerify": insecure_skip_verify,
|
|
149
|
+
"forceHttp1": False,
|
|
150
|
+
"withProtocolRacing": False,
|
|
151
|
+
"disableHttp3": True,
|
|
152
|
+
"withRandomTLSExtensionOrder": False,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
raw = await self.execute_async(payload)
|
|
156
|
+
|
|
157
|
+
status_code = raw.get("status", 0)
|
|
158
|
+
raw_headers = raw.get("headers", {})
|
|
159
|
+
used_protocol = raw.get("usedProtocol", "")
|
|
160
|
+
|
|
161
|
+
# Decode response body
|
|
162
|
+
raw_body = raw.get("body", "")
|
|
163
|
+
body_bytes = b""
|
|
164
|
+
if isinstance(raw_body, str) and raw_body:
|
|
165
|
+
if raw_body.startswith("data:") and "," in raw_body:
|
|
166
|
+
raw_body = raw_body.split(",", 1)[1]
|
|
167
|
+
try:
|
|
168
|
+
body_bytes = base64.b64decode(raw_body)
|
|
169
|
+
except Exception:
|
|
170
|
+
body_bytes = raw_body.encode("utf-8")
|
|
171
|
+
|
|
172
|
+
# Map native failure (status 0) to 502 Bad Gateway
|
|
173
|
+
if status_code == 0:
|
|
174
|
+
status_code = 502
|
|
175
|
+
error_msg = raw.get("errorMessage") or (
|
|
176
|
+
body_bytes.decode("utf-8", errors="replace")
|
|
177
|
+
if body_bytes
|
|
178
|
+
else "Bad Gateway: upstream connection failed"
|
|
179
|
+
)
|
|
180
|
+
body_bytes = (
|
|
181
|
+
error_msg.encode("utf-8")
|
|
182
|
+
if isinstance(error_msg, str)
|
|
183
|
+
else error_msg
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"status": status_code,
|
|
188
|
+
"headers": raw_headers,
|
|
189
|
+
"body": body_bytes,
|
|
190
|
+
"protocol": used_protocol,
|
|
191
|
+
"target": raw.get("target", url),
|
|
192
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
OWNER = "bogdanfinn"
|
|
10
|
+
REPO = "tls-client"
|
|
11
|
+
DEFAULT_VERSION = "1.16.0"
|
|
12
|
+
|
|
13
|
+
RELEASE_DOWNLOAD_URL = (
|
|
14
|
+
f"https://github.com/{OWNER}/{REPO}/releases/download/v{DEFAULT_VERSION}/{{asset}}"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
ASSET_SHA256: dict[str, str] = {
|
|
18
|
+
"tls-client-xgo-1.16.0-darwin-amd64.dylib": "9cb5fd24bceb74cedd4372bdbdf013058c650fa6f8df2a1336918d8ee54128d8",
|
|
19
|
+
"tls-client-xgo-1.16.0-darwin-arm64.dylib": "f3a6f5ffadc4a7f5184d9880380e522fbaf6890c623eae1ac0805071f42f5aaa",
|
|
20
|
+
"tls-client-xgo-1.16.0-linux-386.so": "5ea81308e53e236dae73b35bd4a09af1201191c32713ea2968b85bbcfe94b616",
|
|
21
|
+
"tls-client-xgo-1.16.0-linux-amd64.so": "75f19133e3cb9b16ab3d2dfe3376c7590aab2fe6f2294debac661bfd63dca6a3",
|
|
22
|
+
"tls-client-xgo-1.16.0-linux-arm-5.so": "02cca934f2fc15db658e2d94ed472691224656b696c6531ffec2d0aad2997b2f",
|
|
23
|
+
"tls-client-xgo-1.16.0-linux-arm-6.so": "5c991eca511de6d0ea04f12100c89a7750696213e246226e4050d88bf7e9136b",
|
|
24
|
+
"tls-client-xgo-1.16.0-linux-arm-7.so": "ee9125b4580bf3e1515304e6faeb4ce30eb88f47c5158349a14d38b25448c358",
|
|
25
|
+
"tls-client-xgo-1.16.0-linux-arm64.so": "91f88d35fa64284c811373003d2a26caa3145eaeefd1c0e9576f10724a914332",
|
|
26
|
+
"tls-client-xgo-1.16.0-windows-386.dll": "f41099111446ec3252166b640156a2213d387511e4e80e849ce8b8a0871be47f",
|
|
27
|
+
"tls-client-xgo-1.16.0-windows-amd64.dll": "33d9b5a4a1a902bae57494186ddce91af32fb67326d94ea04776447a81c15934",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_loader_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_system_platform() -> tuple[str, str]:
|
|
34
|
+
"""Detects system OS and CPU architecture."""
|
|
35
|
+
system_os = platform.system().lower()
|
|
36
|
+
machine = platform.machine().lower()
|
|
37
|
+
|
|
38
|
+
if system_os == "darwin":
|
|
39
|
+
return "darwin", ("arm64" if machine == "arm64" else "amd64")
|
|
40
|
+
|
|
41
|
+
if system_os in ("windows", "win32", "cygwin"):
|
|
42
|
+
return "windows", ("amd64" if ctypes.sizeof(ctypes.c_voidp) == 8 else "386")
|
|
43
|
+
|
|
44
|
+
sys_os = "linux"
|
|
45
|
+
if machine in ("aarch64", "arm64"):
|
|
46
|
+
return sys_os, "arm64"
|
|
47
|
+
if machine in ("x86_64", "amd64"):
|
|
48
|
+
return sys_os, "amd64"
|
|
49
|
+
if machine in ("i386", "i486", "i586", "i686", "x86"):
|
|
50
|
+
return sys_os, "386"
|
|
51
|
+
return sys_os, "amd64"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_asset_filename() -> str:
|
|
55
|
+
"""Builds the asset filename for the current platform."""
|
|
56
|
+
sys_os, arch = get_system_platform()
|
|
57
|
+
ext = "dll" if sys_os == "windows" else ("dylib" if sys_os == "darwin" else "so")
|
|
58
|
+
return f"tls-client-xgo-{DEFAULT_VERSION}-{sys_os}-{arch}.{ext}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_cache_directory() -> str:
|
|
62
|
+
"""Returns the persistent directory for storing native binaries."""
|
|
63
|
+
env_dir = os.getenv("JA4PROXY_CACHE_DIR")
|
|
64
|
+
if env_dir:
|
|
65
|
+
os.makedirs(env_dir, exist_ok=True)
|
|
66
|
+
return env_dir
|
|
67
|
+
|
|
68
|
+
if sys.platform == "win32":
|
|
69
|
+
base = os.getenv("LOCALAPPDATA") or os.path.join(
|
|
70
|
+
os.path.expanduser("~"), "AppData", "Local"
|
|
71
|
+
)
|
|
72
|
+
cache_dir = os.path.join(base, "ja4proxy")
|
|
73
|
+
else:
|
|
74
|
+
xdg = os.getenv("XDG_CACHE_HOME") or os.path.join(
|
|
75
|
+
os.path.expanduser("~"), ".cache"
|
|
76
|
+
)
|
|
77
|
+
cache_dir = os.path.join(xdg, "ja4proxy")
|
|
78
|
+
|
|
79
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
80
|
+
return cache_dir
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _compute_sha256(path: str) -> str:
|
|
84
|
+
hasher = hashlib.sha256()
|
|
85
|
+
with open(path, "rb") as f:
|
|
86
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
87
|
+
hasher.update(chunk)
|
|
88
|
+
return hasher.hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_native_library() -> str:
|
|
92
|
+
"""
|
|
93
|
+
Finds or downloads the native Go shared library.
|
|
94
|
+
Resolves in order:
|
|
95
|
+
1. JA4PROXY_LIB_PATH env var
|
|
96
|
+
2. Local ja4proxy cache directory
|
|
97
|
+
3. Existing horaa-tls cache directory (fallback reuse)
|
|
98
|
+
4. Direct download from GitHub releases with SHA-256 verification
|
|
99
|
+
"""
|
|
100
|
+
with _loader_lock:
|
|
101
|
+
custom_path = os.getenv("JA4PROXY_LIB_PATH")
|
|
102
|
+
if custom_path and os.path.exists(custom_path):
|
|
103
|
+
return custom_path
|
|
104
|
+
|
|
105
|
+
asset_name = get_asset_filename()
|
|
106
|
+
cache_dir = get_cache_directory()
|
|
107
|
+
target_path = os.path.join(cache_dir, asset_name)
|
|
108
|
+
|
|
109
|
+
if os.path.exists(target_path):
|
|
110
|
+
expected_hash = ASSET_SHA256.get(asset_name)
|
|
111
|
+
if expected_hash:
|
|
112
|
+
actual_hash = _compute_sha256(target_path)
|
|
113
|
+
if actual_hash == expected_hash:
|
|
114
|
+
return target_path
|
|
115
|
+
|
|
116
|
+
# Check existing horaa-tls cache location
|
|
117
|
+
if sys.platform == "win32":
|
|
118
|
+
local_appdata = os.getenv("LOCALAPPDATA") or ""
|
|
119
|
+
horaa_path = os.path.join(local_appdata, "horaa-tls", asset_name)
|
|
120
|
+
if os.path.exists(horaa_path):
|
|
121
|
+
expected_hash = ASSET_SHA256.get(asset_name)
|
|
122
|
+
if not expected_hash or _compute_sha256(horaa_path) == expected_hash:
|
|
123
|
+
return horaa_path
|
|
124
|
+
else:
|
|
125
|
+
xdg = os.getenv("XDG_CACHE_HOME") or os.path.join(
|
|
126
|
+
os.path.expanduser("~"), ".cache"
|
|
127
|
+
)
|
|
128
|
+
horaa_path = os.path.join(xdg, "horaa-tls", asset_name)
|
|
129
|
+
if os.path.exists(horaa_path):
|
|
130
|
+
expected_hash = ASSET_SHA256.get(asset_name)
|
|
131
|
+
if not expected_hash or _compute_sha256(horaa_path) == expected_hash:
|
|
132
|
+
return horaa_path
|
|
133
|
+
|
|
134
|
+
# Download from GitHub release
|
|
135
|
+
download_url = RELEASE_DOWNLOAD_URL.format(asset=asset_name)
|
|
136
|
+
temp_path = target_path + ".tmp"
|
|
137
|
+
try:
|
|
138
|
+
req = urllib.request.Request(
|
|
139
|
+
download_url, headers={"User-Agent": "ja4proxy/0.1.0"}
|
|
140
|
+
)
|
|
141
|
+
with urllib.request.urlopen(req, timeout=60) as resp, open(
|
|
142
|
+
temp_path, "wb"
|
|
143
|
+
) as out:
|
|
144
|
+
for chunk in iter(lambda: resp.read(65536), b""):
|
|
145
|
+
out.write(chunk)
|
|
146
|
+
|
|
147
|
+
expected_hash = ASSET_SHA256.get(asset_name)
|
|
148
|
+
if expected_hash:
|
|
149
|
+
actual_hash = _compute_sha256(temp_path)
|
|
150
|
+
if actual_hash != expected_hash:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"SHA-256 mismatch for {asset_name}: expected {expected_hash}, got {actual_hash}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
os.replace(temp_path, target_path)
|
|
156
|
+
return target_path
|
|
157
|
+
finally:
|
|
158
|
+
if os.path.exists(temp_path):
|
|
159
|
+
try:
|
|
160
|
+
os.remove(temp_path)
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|