ja4proxy 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → ja4proxy-0.4.0}/PKG-INFO +1 -1
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/__init__.py +1 -1
- ja4proxy-0.4.0/ja4proxy/cli.py +266 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/PKG-INFO +1 -1
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/SOURCES.txt +1 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/pyproject.toml +1 -1
- ja4proxy-0.4.0/tests/test_cli.py +40 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/tests/test_engine.py +18 -6
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/tests/test_live_proxy.py +12 -0
- ja4proxy-0.3.0/ja4proxy/cli.py +0 -161
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/LICENSE +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/README.md +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/__main__.py +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/certs.py +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/engine/bridge.py +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/engine/loader.py +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/parser.py +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/profiles.py +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/server.py +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/dependency_links.txt +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/entry_points.txt +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/requires.txt +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/top_level.txt +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/setup.cfg +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/tests/test_certs.py +0 -0
- {ja4proxy-0.3.0 → ja4proxy-0.4.0}/tests/test_parser.py +0 -0
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
ASCII_BANNER = r"""
|
|
16
|
+
_ __ __
|
|
17
|
+
(_)___ _/ // / ____ _________ _ ____ __
|
|
18
|
+
/ / __ `/ // /_/ __ \/ ___/ __ \| |/_/ / / /
|
|
19
|
+
/ / /_/ /__ __/ /_/ / / / /_/ /> </ /_/ /
|
|
20
|
+
__/ /\__,_/ /_/ / .___/_/ \____/_/|_|\__, /
|
|
21
|
+
/___/ /_/ /____/
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_banner() -> str:
|
|
26
|
+
banner_art = click.style(ASCII_BANNER.strip("\r\n"), fg="cyan", bold=True)
|
|
27
|
+
tagline = click.style(
|
|
28
|
+
" Universal Browser TLS & JA4 Impersonation Proxy",
|
|
29
|
+
fg="white",
|
|
30
|
+
bold=True,
|
|
31
|
+
)
|
|
32
|
+
meta = click.style(
|
|
33
|
+
f" Version: {__version__} | Docs: https://ja4proxy.cipherwire.link",
|
|
34
|
+
fg="bright_black",
|
|
35
|
+
)
|
|
36
|
+
return f"{banner_art}\n{tagline}\n{meta}\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BannerGroup(click.Group):
|
|
40
|
+
"""Custom Click Group that renders the cyberpunk ASCII banner on help."""
|
|
41
|
+
|
|
42
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
43
|
+
formatter.write(render_banner() + "\n")
|
|
44
|
+
super().format_help(ctx, formatter)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group(cls=BannerGroup, no_args_is_help=True)
|
|
48
|
+
@click.version_option(__version__, prog_name="ja4proxy")
|
|
49
|
+
def main() -> None:
|
|
50
|
+
"""High-performance universal forward proxy with authentic browser TLS and HTTP/2 framing."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@main.command()
|
|
55
|
+
@click.option("--host", default="127.0.0.1", help="Binding IP address.")
|
|
56
|
+
@click.option("--port", "-p", default=8888, type=int, help="Port to listen on.")
|
|
57
|
+
@click.option(
|
|
58
|
+
"--profile",
|
|
59
|
+
default="chrome_133",
|
|
60
|
+
help="Default browser profile (e.g. chrome_133, safari_16_0, firefox_133).",
|
|
61
|
+
)
|
|
62
|
+
@click.option(
|
|
63
|
+
"--upstream-proxy",
|
|
64
|
+
default=None,
|
|
65
|
+
help="Default upstream proxy URL (e.g. socks5://user:pass@host:1080).",
|
|
66
|
+
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--auth",
|
|
69
|
+
default=None,
|
|
70
|
+
help="Require username:password authentication for client connections.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--cert-dir", default=None, help="Directory to store CA and leaf certificates."
|
|
74
|
+
)
|
|
75
|
+
@click.option(
|
|
76
|
+
"--allow-private-networks",
|
|
77
|
+
is_flag=True,
|
|
78
|
+
help="Allow forwarding requests to private and loopback IP ranges.",
|
|
79
|
+
)
|
|
80
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose debug logging.")
|
|
81
|
+
def start(
|
|
82
|
+
host: str,
|
|
83
|
+
port: int,
|
|
84
|
+
profile: str,
|
|
85
|
+
upstream_proxy: str | None,
|
|
86
|
+
auth: str | None,
|
|
87
|
+
cert_dir: str | None,
|
|
88
|
+
allow_private_networks: bool,
|
|
89
|
+
verbose: bool,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Starts the ja4proxy server."""
|
|
92
|
+
log_level = logging.DEBUG if verbose else logging.INFO
|
|
93
|
+
logging.basicConfig(
|
|
94
|
+
level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
prof = normalize_profile(profile)
|
|
98
|
+
server = ProxyServer(
|
|
99
|
+
host=host,
|
|
100
|
+
port=port,
|
|
101
|
+
default_profile=prof,
|
|
102
|
+
cert_dir=cert_dir,
|
|
103
|
+
allow_private_networks=allow_private_networks,
|
|
104
|
+
upstream_proxy=upstream_proxy,
|
|
105
|
+
auth=auth,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
ca_path = server.ca.ca_cert_path
|
|
109
|
+
sep = click.style("=" * 72, fg="cyan")
|
|
110
|
+
divider = click.style("-" * 72, fg="bright_black")
|
|
111
|
+
|
|
112
|
+
click.echo(render_banner())
|
|
113
|
+
click.echo(sep)
|
|
114
|
+
click.echo(
|
|
115
|
+
f" {click.style('[*] Engine Status:', fg='bright_white', bold=True)} "
|
|
116
|
+
f"{click.style('ONLINE', fg='green', bold=True)}"
|
|
117
|
+
)
|
|
118
|
+
click.echo(
|
|
119
|
+
f" {click.style('[*] HTTP Ingress:', fg='bright_white', bold=True)} "
|
|
120
|
+
f"{click.style(f'http://{host}:{port}', fg='cyan', underline=True)}"
|
|
121
|
+
)
|
|
122
|
+
click.echo(
|
|
123
|
+
f" {click.style('[*] SOCKS5 Ingress:', fg='bright_white', bold=True)} "
|
|
124
|
+
f"{click.style(f'socks5://{host}:{port}', fg='cyan', underline=True)} "
|
|
125
|
+
f"{click.style('(Auto-detect on same port)', fg='bright_black')}"
|
|
126
|
+
)
|
|
127
|
+
click.echo(
|
|
128
|
+
f" {click.style('[*] Active Profile:', fg='bright_white', bold=True)} "
|
|
129
|
+
f"{click.style(prof, fg='yellow', bold=True)}"
|
|
130
|
+
)
|
|
131
|
+
upstream_desc = upstream_proxy if upstream_proxy else "DIRECT"
|
|
132
|
+
click.echo(
|
|
133
|
+
f" {click.style('[*] Upstream Proxy:', fg='bright_white', bold=True)} "
|
|
134
|
+
f"{click.style(upstream_desc, fg='bright_white')}"
|
|
135
|
+
)
|
|
136
|
+
auth_desc = "Enabled (HTTP Basic / SOCKS5)" if auth else "None (Open)"
|
|
137
|
+
click.echo(
|
|
138
|
+
f" {click.style('[*] Authentication:', fg='bright_white', bold=True)} "
|
|
139
|
+
f"{click.style(auth_desc, fg='bright_white')}"
|
|
140
|
+
)
|
|
141
|
+
ssrf_status = (
|
|
142
|
+
click.style("PERMISSIVE (--allow-private-networks)", fg="yellow")
|
|
143
|
+
if allow_private_networks
|
|
144
|
+
else click.style("ENFORCED (RFC1918 & Loopback blocked)", fg="green")
|
|
145
|
+
)
|
|
146
|
+
click.echo(
|
|
147
|
+
f" {click.style('[*] SSRF Guard:', fg='bright_white', bold=True)} "
|
|
148
|
+
f"{ssrf_status}"
|
|
149
|
+
)
|
|
150
|
+
click.echo(
|
|
151
|
+
f" {click.style('[*] Root CA Path:', fg='bright_white', bold=True)} "
|
|
152
|
+
f"{click.style(ca_path, fg='bright_black')}"
|
|
153
|
+
)
|
|
154
|
+
click.echo(divider)
|
|
155
|
+
click.echo(
|
|
156
|
+
f" {click.style('Quick Test Command:', fg='bright_white', bold=True)}"
|
|
157
|
+
)
|
|
158
|
+
click.echo(
|
|
159
|
+
f" {click.style(f'curl -x http://{host}:{port} https://tls.peet.ws/api/all', fg='yellow')}"
|
|
160
|
+
)
|
|
161
|
+
click.echo(sep)
|
|
162
|
+
click.echo("")
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
asyncio.run(server.serve_forever())
|
|
166
|
+
except KeyboardInterrupt:
|
|
167
|
+
click.echo(click.style("\n[*] Shutting down ja4proxy...", fg="yellow"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@main.command(name="profiles")
|
|
171
|
+
def list_profiles() -> None:
|
|
172
|
+
"""Lists all supported browser profiles for TLS and HTTP/2 emulation."""
|
|
173
|
+
click.echo(render_banner())
|
|
174
|
+
click.echo(click.style("Available Browser Profiles (JA4 / TLS / HTTP/2):", fg="bright_white", bold=True))
|
|
175
|
+
click.echo("")
|
|
176
|
+
|
|
177
|
+
chrome_profs = [p for p in SUPPORTED_PROFILES if p.startswith("chrome")]
|
|
178
|
+
safari_profs = [p for p in SUPPORTED_PROFILES if p.startswith("safari")]
|
|
179
|
+
firefox_profs = [p for p in SUPPORTED_PROFILES if p.startswith("firefox")]
|
|
180
|
+
|
|
181
|
+
click.echo(click.style(" Google Chrome / Chromium:", fg="cyan", bold=True))
|
|
182
|
+
for p in chrome_profs:
|
|
183
|
+
tag = click.style(" (Default)", fg="green", bold=True) if p == "chrome_133" else ""
|
|
184
|
+
click.echo(f" - {p}{tag}")
|
|
185
|
+
|
|
186
|
+
click.echo("")
|
|
187
|
+
click.echo(click.style(" Apple Safari & iOS WebKit:", fg="cyan", bold=True))
|
|
188
|
+
for p in safari_profs:
|
|
189
|
+
click.echo(f" - {p}")
|
|
190
|
+
|
|
191
|
+
click.echo("")
|
|
192
|
+
click.echo(click.style(" Mozilla Firefox:", fg="cyan", bold=True))
|
|
193
|
+
for p in firefox_profs:
|
|
194
|
+
click.echo(f" - {p}")
|
|
195
|
+
click.echo("")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@main.command(name="ca")
|
|
199
|
+
@click.option(
|
|
200
|
+
"--export",
|
|
201
|
+
"-e",
|
|
202
|
+
"export_path",
|
|
203
|
+
default=None,
|
|
204
|
+
help="Export root CA certificate (ca.pem) to specified path.",
|
|
205
|
+
)
|
|
206
|
+
def manage_ca(export_path: str | None) -> None:
|
|
207
|
+
"""Inspects or exports the local Root CA certificate."""
|
|
208
|
+
ca = CertificateAuthority()
|
|
209
|
+
click.echo(render_banner())
|
|
210
|
+
sep = click.style("-" * 72, fg="bright_black")
|
|
211
|
+
click.echo(sep)
|
|
212
|
+
click.echo(f" {click.style('Root CA Certificate:', fg='bright_white', bold=True)} {ca.ca_cert_path}")
|
|
213
|
+
click.echo(f" {click.style('Root CA Private Key:', fg='bright_white', bold=True)} {ca.ca_key_path}")
|
|
214
|
+
click.echo(sep)
|
|
215
|
+
|
|
216
|
+
if export_path:
|
|
217
|
+
dest = os.path.abspath(export_path)
|
|
218
|
+
shutil.copyfile(ca.ca_cert_path, dest)
|
|
219
|
+
click.echo(click.style(f"[+] Successfully exported CA to: {dest}", fg="green", bold=True))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@main.command(name="test")
|
|
223
|
+
@click.option(
|
|
224
|
+
"--profile",
|
|
225
|
+
default="chrome_133",
|
|
226
|
+
help="Browser profile to test against tls.peet.ws.",
|
|
227
|
+
)
|
|
228
|
+
def test_handshake(profile: str) -> None:
|
|
229
|
+
"""Performs a direct live handshake against tls.peet.ws to inspect upstream JA4."""
|
|
230
|
+
import json
|
|
231
|
+
|
|
232
|
+
prof = normalize_profile(profile)
|
|
233
|
+
click.echo(render_banner())
|
|
234
|
+
click.echo(f"[*] Testing upstream TLS handshake with profile: {click.style(prof, fg='yellow', bold=True)}...")
|
|
235
|
+
|
|
236
|
+
async def _test():
|
|
237
|
+
engine = NativeEngine.get_instance()
|
|
238
|
+
res = await engine.request(
|
|
239
|
+
method="GET",
|
|
240
|
+
url="https://tls.peet.ws/api/all",
|
|
241
|
+
profile=prof,
|
|
242
|
+
timeout_seconds=15,
|
|
243
|
+
)
|
|
244
|
+
return res
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
res = asyncio.run(_test())
|
|
248
|
+
sep = click.style("-" * 72, fg="bright_black")
|
|
249
|
+
click.echo(sep)
|
|
250
|
+
status_color = "green" if res["status"] == 200 else "red"
|
|
251
|
+
click.echo(f" {click.style('Status Code:', fg='bright_white', bold=True)} {click.style(str(res['status']), fg=status_color, bold=True)}")
|
|
252
|
+
click.echo(f" {click.style('Negotiated Protocol:', fg='bright_white', bold=True)} {click.style(res['protocol'], fg='cyan')}")
|
|
253
|
+
|
|
254
|
+
body = json.loads(res["body"].decode("utf-8", errors="replace"))
|
|
255
|
+
tls_info = body.get("tls", {})
|
|
256
|
+
click.echo(f" {click.style('Remote Peer JA4:', fg='bright_white', bold=True)} {click.style(str(tls_info.get('ja4')), fg='yellow', bold=True)}")
|
|
257
|
+
click.echo(f" {click.style('Remote Peer JA3:', fg='bright_white', bold=True)} {tls_info.get('ja3')}")
|
|
258
|
+
click.echo(f" {click.style('HTTP Version:', fg='bright_white', bold=True)} {body.get('http_version')}")
|
|
259
|
+
click.echo(sep)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
click.echo(click.style(f"Error executing test: {e}", fg="red", bold=True), err=True)
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
main()
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ja4proxy"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "High-performance universal proxy server with browser TLS (JA4) and HTTP/2 impersonation"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "cipherwire", email = "dev@cipherwire.link" }]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from click.testing import CliRunner
|
|
3
|
+
|
|
4
|
+
from ja4proxy import __version__
|
|
5
|
+
from ja4proxy.cli import main
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestCLI(unittest.TestCase):
|
|
9
|
+
def setUp(self):
|
|
10
|
+
self.runner = CliRunner()
|
|
11
|
+
|
|
12
|
+
def test_cli_help_displays_banner_and_version(self):
|
|
13
|
+
result = self.runner.invoke(main, ["--help"])
|
|
14
|
+
self.assertEqual(result.exit_code, 0)
|
|
15
|
+
self.assertIn("Universal Browser TLS & JA4 Impersonation Proxy", result.output)
|
|
16
|
+
self.assertIn(__version__, result.output)
|
|
17
|
+
self.assertIn("https://ja4proxy.cipherwire.link", result.output)
|
|
18
|
+
self.assertIn("start", result.output)
|
|
19
|
+
self.assertIn("profiles", result.output)
|
|
20
|
+
self.assertIn("ca", result.output)
|
|
21
|
+
|
|
22
|
+
def test_cli_version_flag(self):
|
|
23
|
+
result = self.runner.invoke(main, ["--version"])
|
|
24
|
+
self.assertEqual(result.exit_code, 0)
|
|
25
|
+
self.assertIn(f"ja4proxy, version {__version__}", result.output)
|
|
26
|
+
|
|
27
|
+
def test_cli_profiles_command(self):
|
|
28
|
+
result = self.runner.invoke(main, ["profiles"])
|
|
29
|
+
self.assertEqual(result.exit_code, 0)
|
|
30
|
+
self.assertIn("Available Browser Profiles", result.output)
|
|
31
|
+
self.assertIn("chrome_133 (Default)", result.output)
|
|
32
|
+
self.assertIn("firefox_133", result.output)
|
|
33
|
+
self.assertIn("safari_16_0", result.output)
|
|
34
|
+
|
|
35
|
+
def test_cli_ca_command(self):
|
|
36
|
+
result = self.runner.invoke(main, ["ca"])
|
|
37
|
+
self.assertEqual(result.exit_code, 0)
|
|
38
|
+
self.assertIn("Root CA Certificate:", result.output)
|
|
39
|
+
self.assertIn("ca.pem", result.output)
|
|
40
|
+
self.assertIn("Root CA Private Key:", result.output)
|
|
@@ -11,14 +11,26 @@ class TestNativeEngine(unittest.TestCase):
|
|
|
11
11
|
|
|
12
12
|
def test_live_handshake_chrome(self):
|
|
13
13
|
async def _run():
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
last_res = None
|
|
15
|
+
for attempt in range(3):
|
|
16
|
+
try:
|
|
17
|
+
res = await self.engine.request(
|
|
18
|
+
method="GET",
|
|
19
|
+
url="https://tls.peet.ws/api/all",
|
|
20
|
+
profile="chrome_133",
|
|
21
|
+
timeout_seconds=15,
|
|
22
|
+
)
|
|
23
|
+
if res.get("status") == 200:
|
|
24
|
+
return res
|
|
25
|
+
last_res = res
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
last_res = {"status": 0, "body": str(exc).encode()}
|
|
28
|
+
await asyncio.sleep(1.0 * (attempt + 1))
|
|
29
|
+
return last_res
|
|
20
30
|
|
|
21
31
|
res = asyncio.run(_run())
|
|
32
|
+
if res and res.get("status") in (429, 502, 503, 504):
|
|
33
|
+
self.skipTest(f"tls.peet.ws rate limit or transient upstream status {res.get('status')}")
|
|
22
34
|
self.assertEqual(res["status"], 200)
|
|
23
35
|
self.assertTrue(len(res["body"]) > 0)
|
|
24
36
|
data = json.loads(res["body"].decode("utf-8"))
|
|
@@ -76,6 +76,10 @@ class TestLiveProxy(unittest.IsolatedAsyncioTestCase):
|
|
|
76
76
|
|
|
77
77
|
# Read response
|
|
78
78
|
status_line = await reader.readline()
|
|
79
|
+
if b"502" in status_line or b"429" in status_line or b"503" in status_line:
|
|
80
|
+
writer.close()
|
|
81
|
+
await writer.wait_closed()
|
|
82
|
+
self.skipTest(f"tls.peet.ws public service returned transient error: {status_line.decode('utf-8', errors='replace').strip()}")
|
|
79
83
|
self.assertIn(b"200 OK", status_line)
|
|
80
84
|
|
|
81
85
|
headers = {}
|
|
@@ -115,6 +119,10 @@ class TestLiveProxy(unittest.IsolatedAsyncioTestCase):
|
|
|
115
119
|
await writer.drain()
|
|
116
120
|
|
|
117
121
|
status_line = await reader.readline()
|
|
122
|
+
if b"502" in status_line or b"429" in status_line or b"503" in status_line:
|
|
123
|
+
writer.close()
|
|
124
|
+
await writer.wait_closed()
|
|
125
|
+
self.skipTest(f"tls.peet.ws public service returned transient error: {status_line.decode('utf-8', errors='replace').strip()}")
|
|
118
126
|
self.assertIn(b"200 OK", status_line)
|
|
119
127
|
|
|
120
128
|
while True:
|
|
@@ -226,6 +234,10 @@ class TestLiveProxy(unittest.IsolatedAsyncioTestCase):
|
|
|
226
234
|
await writer.drain()
|
|
227
235
|
|
|
228
236
|
status_line = await reader.readline()
|
|
237
|
+
if b"502" in status_line or b"429" in status_line or b"503" in status_line:
|
|
238
|
+
writer.close()
|
|
239
|
+
await writer.wait_closed()
|
|
240
|
+
self.skipTest(f"tls.peet.ws public service returned transient error: {status_line.decode('utf-8', errors='replace').strip()}")
|
|
229
241
|
self.assertIn(b"200 OK", status_line)
|
|
230
242
|
|
|
231
243
|
while True:
|
ja4proxy-0.3.0/ja4proxy/cli.py
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
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_16_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
|
-
"--auth",
|
|
38
|
-
default=None,
|
|
39
|
-
help="Require username:password authentication for client connections.",
|
|
40
|
-
)
|
|
41
|
-
@click.option(
|
|
42
|
-
"--cert-dir", default=None, help="Directory to store CA and leaf certificates."
|
|
43
|
-
)
|
|
44
|
-
@click.option(
|
|
45
|
-
"--allow-private-networks",
|
|
46
|
-
is_flag=True,
|
|
47
|
-
help="Allow forwarding requests to private and loopback IP ranges.",
|
|
48
|
-
)
|
|
49
|
-
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose debug logging.")
|
|
50
|
-
def start(
|
|
51
|
-
host: str,
|
|
52
|
-
port: int,
|
|
53
|
-
profile: str,
|
|
54
|
-
upstream_proxy: str | None,
|
|
55
|
-
auth: str | None,
|
|
56
|
-
cert_dir: str | None,
|
|
57
|
-
allow_private_networks: bool,
|
|
58
|
-
verbose: bool,
|
|
59
|
-
) -> None:
|
|
60
|
-
"""Starts the ja4proxy server."""
|
|
61
|
-
log_level = logging.DEBUG if verbose else logging.INFO
|
|
62
|
-
logging.basicConfig(
|
|
63
|
-
level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
prof = normalize_profile(profile)
|
|
67
|
-
server = ProxyServer(
|
|
68
|
-
host=host,
|
|
69
|
-
port=port,
|
|
70
|
-
default_profile=prof,
|
|
71
|
-
cert_dir=cert_dir,
|
|
72
|
-
allow_private_networks=allow_private_networks,
|
|
73
|
-
upstream_proxy=upstream_proxy,
|
|
74
|
-
auth=auth,
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
ca_path = server.ca.ca_cert_path
|
|
78
|
-
click.echo(
|
|
79
|
-
f"[*] ja4proxy v{__version__} starting on http://{host}:{port}"
|
|
80
|
-
)
|
|
81
|
-
click.echo(f"[*] Default Profile: {prof}")
|
|
82
|
-
click.echo(f"[*] Protocols Supported: HTTP CONNECT, HTTP Gateway, SOCKS5 (Auto-detect)")
|
|
83
|
-
click.echo(f"[*] CA Certificate: {ca_path}")
|
|
84
|
-
click.echo(
|
|
85
|
-
"[*] Ready for HTTP and SOCKS5 clients (Python requests, Node axios, cURL, etc.)"
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
try:
|
|
89
|
-
asyncio.run(server.serve_forever())
|
|
90
|
-
except KeyboardInterrupt:
|
|
91
|
-
click.echo("\n[*] Shutting down ja4proxy...")
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@main.command(name="profiles")
|
|
95
|
-
def list_profiles() -> None:
|
|
96
|
-
"""Lists all supported browser profiles for TLS and HTTP/2 emulation."""
|
|
97
|
-
click.echo("Supported Browser Profiles:")
|
|
98
|
-
for prof in SUPPORTED_PROFILES:
|
|
99
|
-
click.echo(f" - {prof}")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
@main.command(name="ca")
|
|
103
|
-
@click.option(
|
|
104
|
-
"--export",
|
|
105
|
-
"-e",
|
|
106
|
-
"export_path",
|
|
107
|
-
default=None,
|
|
108
|
-
help="Export root CA certificate (ca.pem) to specified path.",
|
|
109
|
-
)
|
|
110
|
-
def manage_ca(export_path: str | None) -> None:
|
|
111
|
-
"""Inspects or exports the local Root CA certificate."""
|
|
112
|
-
ca = CertificateAuthority()
|
|
113
|
-
click.echo(f"Root CA Certificate: {ca.ca_cert_path}")
|
|
114
|
-
click.echo(f"Root CA Private Key: {ca.ca_key_path}")
|
|
115
|
-
|
|
116
|
-
if export_path:
|
|
117
|
-
dest = os.path.abspath(export_path)
|
|
118
|
-
shutil.copyfile(ca.ca_cert_path, dest)
|
|
119
|
-
click.echo(f"Exported CA to: {dest}")
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
@main.command(name="test")
|
|
123
|
-
@click.option(
|
|
124
|
-
"--profile",
|
|
125
|
-
default="chrome_133",
|
|
126
|
-
help="Browser profile to test against tls.peet.ws.",
|
|
127
|
-
)
|
|
128
|
-
def test_handshake(profile: str) -> None:
|
|
129
|
-
"""Performs a direct live handshake against tls.peet.ws to inspect upstream JA4."""
|
|
130
|
-
import json
|
|
131
|
-
|
|
132
|
-
prof = normalize_profile(profile)
|
|
133
|
-
click.echo(f"Testing upstream handshake for profile: {prof}...")
|
|
134
|
-
|
|
135
|
-
async def _test():
|
|
136
|
-
engine = NativeEngine.get_instance()
|
|
137
|
-
res = await engine.request(
|
|
138
|
-
method="GET",
|
|
139
|
-
url="https://tls.peet.ws/api/all",
|
|
140
|
-
profile=prof,
|
|
141
|
-
timeout_seconds=15,
|
|
142
|
-
)
|
|
143
|
-
return res
|
|
144
|
-
|
|
145
|
-
try:
|
|
146
|
-
res = asyncio.run(_test())
|
|
147
|
-
click.echo(f"Status Code: {res['status']}")
|
|
148
|
-
click.echo(f"Negotiated Protocol: {res['protocol']}")
|
|
149
|
-
|
|
150
|
-
body = json.loads(res["body"].decode("utf-8", errors="replace"))
|
|
151
|
-
tls_info = body.get("tls", {})
|
|
152
|
-
click.echo(f"Remote Peer JA4: {tls_info.get('ja4')}")
|
|
153
|
-
click.echo(f"Remote Peer JA3: {tls_info.get('ja3')}")
|
|
154
|
-
click.echo(f"HTTP Version: {body.get('http_version')}")
|
|
155
|
-
except Exception as e:
|
|
156
|
-
click.echo(f"Error executing test: {e}", err=True)
|
|
157
|
-
sys.exit(1)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if __name__ == "__main__":
|
|
161
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|