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.
Files changed (26) hide show
  1. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/PKG-INFO +1 -1
  2. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/__init__.py +1 -1
  3. ja4proxy-0.4.0/ja4proxy/cli.py +266 -0
  4. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/PKG-INFO +1 -1
  5. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/SOURCES.txt +1 -0
  6. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/pyproject.toml +1 -1
  7. ja4proxy-0.4.0/tests/test_cli.py +40 -0
  8. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/tests/test_engine.py +18 -6
  9. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/tests/test_live_proxy.py +12 -0
  10. ja4proxy-0.3.0/ja4proxy/cli.py +0 -161
  11. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/LICENSE +0 -0
  12. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/README.md +0 -0
  13. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/__main__.py +0 -0
  14. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/certs.py +0 -0
  15. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/engine/bridge.py +0 -0
  16. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/engine/loader.py +0 -0
  17. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/parser.py +0 -0
  18. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/profiles.py +0 -0
  19. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy/server.py +0 -0
  20. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/dependency_links.txt +0 -0
  21. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/entry_points.txt +0 -0
  22. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/requires.txt +0 -0
  23. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/ja4proxy.egg-info/top_level.txt +0 -0
  24. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/setup.cfg +0 -0
  25. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/tests/test_certs.py +0 -0
  26. {ja4proxy-0.3.0 → ja4proxy-0.4.0}/tests/test_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ja4proxy
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: High-performance universal proxy server with browser TLS (JA4) and HTTP/2 impersonation
5
5
  Author-email: cipherwire <dev@cipherwire.link>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  """ja4proxy: High-performance universal proxy server with browser TLS (JA4) and HTTP/2 impersonation."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
  __author__ = "cipherwire"
5
5
 
6
6
  from ja4proxy.engine.bridge import NativeEngine
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ja4proxy
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: High-performance universal proxy server with browser TLS (JA4) and HTTP/2 impersonation
5
5
  Author-email: cipherwire <dev@cipherwire.link>
6
6
  License-Expression: MIT
@@ -17,6 +17,7 @@ ja4proxy.egg-info/top_level.txt
17
17
  ja4proxy/engine/bridge.py
18
18
  ja4proxy/engine/loader.py
19
19
  tests/test_certs.py
20
+ tests/test_cli.py
20
21
  tests/test_engine.py
21
22
  tests/test_live_proxy.py
22
23
  tests/test_parser.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ja4proxy"
7
- version = "0.3.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
- return await self.engine.request(
15
- method="GET",
16
- url="https://tls.peet.ws/api/all",
17
- profile="chrome_133",
18
- timeout_seconds=15,
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:
@@ -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