ja3-client 1.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: ja3-client
3
+ Version: 1.2.5
4
+ Summary: Python binding for the ja3-client TLS-impersonation shared library
5
+ Author: smallerqiu
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/smallerqiu/ja3-client
8
+ Project-URL: Repository, https://github.com/smallerqiu/ja3-client
9
+ Project-URL: Issues, https://github.com/smallerqiu/ja3-client/issues
10
+ Project-URL: Documentation, https://github.com/smallerqiu/ja3-client/blob/master/cffi/README.md
11
+ Keywords: ja3,ja4,tls,impersonate,fingerprint,ffi,ctypes
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # ja3-client (Python)
26
+
27
+ Browser TLS/H2/H3 fingerprint impersonation from Python, through a `ctypes` binding to
28
+ the Go shared library. Pure standard library - no build step, no dependencies. The ABI
29
+ contract (payload keys, response envelope, error codes) is in
30
+ [cffi/README.md](../README.md); this file covers installing the binary the binding needs.
31
+
32
+ ```python
33
+ from ja3_client import Ja3
34
+
35
+ ja3 = Ja3()
36
+ resp = ja3.request("https://tls.browserleaks.com/json", impersonate="chrome_153")
37
+ print(resp.json()["ja3n_hash"])
38
+
39
+ with ja3.session(impersonate="chrome_133") as session:
40
+ session.get("https://example.com/") # same pool + cookie jar
41
+ ```
42
+
43
+ ## Where the library comes from
44
+
45
+ `pip install ja3-client` installs binding code only. A wheel cannot run code at install
46
+ time, so the native library is fetched the first time `Ja3()` is constructed: the
47
+ resolver downloads `ja3-client-<version>-<os>-<arch>.zip` from this repository's GitHub
48
+ Release for the installed version, checks it against the release's `SHA256SUMS.txt`, and
49
+ unpacks the library into a cache shared with the Node and C# bindings:
50
+
51
+ ```
52
+ ~/.cache/ja3-client/<version>/<os>-<arch>/ (macOS: ~/Library/Caches, Windows: %LOCALAPPDATA%)
53
+ ```
54
+
55
+ To fetch it explicitly - before a container build, or on a machine that will later be
56
+ offline:
57
+
58
+ ```sh
59
+ ja3-client ensure # or: python -m ja3_client_binaries ensure
60
+ ja3-client print-url # the archive this machine wants
61
+ ja3-client status # version, slug, cache dir, what is present
62
+ ```
63
+
64
+ At runtime the binding looks for, in order: the `library_path` you pass to `Ja3()`,
65
+ `JA3_CLIENT_LIB`, a library beside it or under `cffi/build`, any version in the shared
66
+ cache, and only then a download.
67
+
68
+ ## Knobs
69
+
70
+ | variable | effect |
71
+ | --- | --- |
72
+ | `JA3_CLIENT_LIB` | use this exact file; never search, never download |
73
+ | `JA3_CLIENT_CACHE_DIR` | move the cache (also how you point CI at a mirror) |
74
+ | `JA3_CLIENT_VERSION` | library version to fetch, overriding the package version |
75
+ | `JA3_CLIENT_BASE_URL` | download root, for a mirror or an offline test server |
76
+ | `JA3_CLIENT_REPO` | `owner/repo` if you build the library from a fork |
77
+ | `JA3_CLIENT_OFFLINE=1` | never touch the network; fail with the manual recipe |
78
+
79
+ ## No network, no GitHub
80
+
81
+ Build it and pin it, or unzip a release archive and point `JA3_CLIENT_LIB` at the file
82
+ inside - every archive carries `smoke.py`, which proves the download offline:
83
+
84
+ ```sh
85
+ git clone https://github.com/smallerqiu/ja3-client && cd ja3-client/cffi && make host
86
+ export JA3_CLIENT_LIB="$PWD/build/darwin/ja3_client_arm64.dylib" # linux: libja3_client_amd64.so
87
+ ```
88
+
89
+ Linux archives are built against glibc (Debian bookworm); on musl (Alpine) build the
90
+ library yourself.
@@ -0,0 +1,8 @@
1
+ ja3_client.py,sha256=8MLW1hyCUSdca13RgP2ymNMwVLXJMA_nN-WXjyxCBWw,17436
2
+ ja3_client_binaries.py,sha256=38YLaTAiiIB3mKeUTOh4fjdyBJWpOYsvOMyFnIu1aL4,15935
3
+ ja3_client-1.2.5.dist-info/licenses/LICENSE,sha256=DhGbA1FlqMafTzs5mW0vJFeMrGRSF_z7pUiwCw4rHNg,1067
4
+ ja3_client-1.2.5.dist-info/METADATA,sha256=DeAMCje837iNG2DNEM-Qu_ivJysgMNz-FHruG_jdhXI,3726
5
+ ja3_client-1.2.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ ja3_client-1.2.5.dist-info/entry_points.txt,sha256=QEmIATNcARQPCiOlQv5Hm81XbitAR0d2rfuqXyRqVa4,56
7
+ ja3_client-1.2.5.dist-info/top_level.txt,sha256=85v4t8RM2ZXcqKU4XjfrvTveEyjE_v1cOp6DwV4h_Ro,31
8
+ ja3_client-1.2.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ja3-client = ja3_client_binaries:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 smallerqiu
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,2 @@
1
+ ja3_client
2
+ ja3_client_binaries
ja3_client.py ADDED
@@ -0,0 +1,526 @@
1
+ """Python bindings for the ja3-client shared library (``libja3-client``).
2
+
3
+ Pure standard library: ``ctypes`` + ``json`` + ``base64``. No build step, no
4
+ third-party packages.
5
+
6
+ from ja3_client import Ja3
7
+
8
+ ja3 = Ja3() # finds and loads the library
9
+ print(ja3.version())
10
+ resp = ja3.request("https://tls.browserleaks.com/json", impersonate="chrome_133")
11
+ print(resp.status_code, resp.json()["ja3n_hash"])
12
+
13
+ with ja3.session(impersonate="chrome_133") as session:
14
+ session.get("https://example.com/")
15
+ session.get("https://example.com/about") # same connection pool + cookies
16
+
17
+ Every call crosses the ABI as one JSON string, so the payload keys and the response
18
+ keys are documented in ``cffi/README.md`` (the ``snake_case`` contract). Unknown keys
19
+ come back in ``Response.warnings`` instead of being dropped.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import ctypes
26
+ import json
27
+ import os
28
+ import platform
29
+ from typing import Any, Iterable, Iterator, Mapping
30
+
31
+ __all__ = [
32
+ "Ja3",
33
+ "Ja3Error",
34
+ "Response",
35
+ "Session",
36
+ "ABI_VERSION",
37
+ "ERROR_CODES",
38
+ ]
39
+
40
+ ABI_VERSION = 2
41
+
42
+ #: ``error_code`` -> name. Mirrors the Go constants in cffi/ffi.go.
43
+ ERROR_CODES = {
44
+ 0: "OK",
45
+ 1: "NIL_ARGUMENT",
46
+ 2: "BAD_JSON",
47
+ 3: "SESSION_INIT",
48
+ 4: "INVALID_HANDLE",
49
+ 5: "REQUEST",
50
+ 6: "READ_BODY",
51
+ 7: "MARSHAL",
52
+ 8: "PANIC",
53
+ }
54
+
55
+ def _default_library_names() -> Iterator[str]:
56
+ """Candidate file names for the current platform, most specific first."""
57
+ system = platform.system()
58
+ machine = platform.machine().lower()
59
+
60
+ arch = {
61
+ "amd64": "amd64",
62
+ "x86_64": "amd64",
63
+ "arm64": "arm64",
64
+ "aarch64": "arm64",
65
+ "i386": "386",
66
+ "i686": "386",
67
+ }.get(machine, machine)
68
+
69
+ if system == "Darwin":
70
+ yield f"ja3_client_{arch}.dylib"
71
+ yield "libja3-client.dylib"
72
+ elif system == "Windows":
73
+ yield f"ja3_client_{arch}.dll"
74
+ yield "ja3-client.dll"
75
+ else:
76
+ yield f"libja3_client_{arch}.so"
77
+
78
+ if machine.startswith("arm"):
79
+ # 32-bit ARM reports armv6l/armv7l; the release names them armv7/arm.
80
+ yield "libja3_client_armv7.so"
81
+ yield "libja3_client_arm.so"
82
+
83
+ yield "libja3-client.so"
84
+
85
+
86
+ #: The installer module, or None when it is not installed. Undefined = not tried yet;
87
+ #: release archives ship ja3_client.py alone, so its absence is normal, not an error.
88
+ _installer: Any = None
89
+ _installer_tried = False
90
+
91
+
92
+ def _through_installer() -> str | None:
93
+ """Reuse the shared cache, then fetch, through ja3_client_binaries if installed.
94
+
95
+ A failed fetch raises with the installer's own three-line recipe: when the problem
96
+ is a 404 on a release asset, "I looked at these twelve paths" is not the message
97
+ that helps.
98
+ """
99
+ global _installer, _installer_tried
100
+
101
+ if not _installer_tried:
102
+ _installer_tried = True
103
+
104
+ try:
105
+ import ja3_client_binaries as module
106
+
107
+ _installer = module
108
+ except Exception:
109
+ _installer = None
110
+
111
+ if _installer is None:
112
+ return None
113
+
114
+ cached = _installer.find_cached_library(list(_default_library_names()))
115
+
116
+ if cached:
117
+ return cached
118
+
119
+ try:
120
+ return _installer.ensure_library()
121
+ except Exception as err:
122
+ raise Ja3Error(str(err), error_code=1) from err
123
+
124
+
125
+ #: Options the library only reads when it builds the client. Re-sending them on a
126
+ #: session request would be ignored, so the binding drops them there.
127
+ _CLIENT_ONLY_KEYS = (
128
+ "impersonate", "ja3", "akamai", "client_data", "proxy", "source_ip", "timeout",
129
+ "force_http1", "with_http3", "random_extension_order", "with_debug",
130
+ "not_follow_redirects",
131
+ )
132
+
133
+
134
+ class Ja3Error(RuntimeError):
135
+ """Raised when the library reports a non-zero ``error_code``."""
136
+
137
+ def __init__(self, message: str, error_code: int, payload: Mapping[str, Any] | None = None):
138
+ super().__init__(message)
139
+ self.error_code = error_code
140
+ self.name = ERROR_CODES.get(error_code, f"UNKNOWN_{error_code}")
141
+ self.payload = dict(payload or {})
142
+
143
+
144
+ class Response:
145
+ """One successful library call, decoded from the JSON envelope."""
146
+
147
+ __slots__ = ("status_code", "protocol", "headers", "body", "body_size", "set_cookies",
148
+ "warnings", "session", "raw")
149
+
150
+ def __init__(self, payload: Mapping[str, Any]):
151
+ self.raw = dict(payload)
152
+ self.status_code: int = payload.get("status_code", 0)
153
+ self.protocol: str = payload.get("protocol", "")
154
+ self.headers: dict[str, list[str]] = payload.get("headers", {})
155
+ self.set_cookies: list[str] = payload.get("set_cookies", [])
156
+ self.warnings: list[str] = payload.get("warnings", [])
157
+ self.session: int = payload.get("session", 0)
158
+ self.body_size: int = payload.get("body_size", 0)
159
+
160
+ if payload.get("body_base64"):
161
+ self.body: bytes = base64.b64decode(payload["body_base64"])
162
+ else:
163
+ self.body = payload.get("body", "").encode("utf-8")
164
+
165
+ # -- header access -------------------------------------------------------
166
+
167
+ def header(self, name: str, default: str | None = None) -> str | None:
168
+ """First value of ``name``, case-insensitive."""
169
+ wanted = name.lower()
170
+
171
+ for key, values in self.headers.items():
172
+ if key.lower() == wanted:
173
+ return values[0] if values else default
174
+
175
+ return default
176
+
177
+ # -- body access ---------------------------------------------------------
178
+
179
+ @property
180
+ def text(self) -> str:
181
+ return self.body.decode("utf-8", errors="replace")
182
+
183
+ @property
184
+ def content(self) -> bytes:
185
+ return self.body
186
+
187
+ def json(self) -> Any:
188
+ return json.loads(self.text)
189
+
190
+ @property
191
+ def ok(self) -> bool:
192
+ return 200 <= self.status_code < 400
193
+
194
+ def raise_for_status(self) -> "Response":
195
+ if not self.ok:
196
+ raise Ja3Error(f"HTTP {self.status_code}", error_code=5, payload=self.raw)
197
+
198
+ return self
199
+
200
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
201
+ return f"<Response {self.status_code} {self.protocol} {self.body_size}B>"
202
+
203
+
204
+ class Session:
205
+ """A handle to a persistent client: connection pool + cookie jar reused."""
206
+
207
+ def __init__(self, ja3: "Ja3", handle: int, options: Mapping[str, Any]):
208
+ self._ja3 = ja3
209
+ self._handle = handle
210
+ self._options = dict(options)
211
+ self._closed = False
212
+
213
+ @property
214
+ def handle(self) -> int:
215
+ return self._handle
216
+
217
+ @property
218
+ def closed(self) -> bool:
219
+ return self._closed
220
+
221
+ def request(self, url: str, *, method: str = "GET",
222
+ headers: Mapping[str, Any] | None = None,
223
+ body: Any = None, **options: Any) -> Response:
224
+ if self._closed:
225
+ raise Ja3Error("session is closed", error_code=4)
226
+
227
+ payload = _build_payload(url, method, headers, body, options)
228
+ # Client-level options were fixed at ja3_session_new; sending them again
229
+ # would silently do nothing, so keep only the per-call fields.
230
+ for client_key in _CLIENT_ONLY_KEYS:
231
+ payload.pop(client_key, None)
232
+
233
+ return self._ja3._session_request(self._handle, payload)
234
+
235
+ def get(self, url: str, **options: Any) -> Response:
236
+ return self.request(url, method="GET", **options)
237
+
238
+ def post(self, url: str, body: Any = None, **options: Any) -> Response:
239
+ return self.request(url, method="POST", body=body, **options)
240
+
241
+ def close(self) -> None:
242
+ if self._closed:
243
+ return
244
+
245
+ self._ja3._session_close(self._handle)
246
+ self._closed = True
247
+
248
+ def __enter__(self) -> "Session":
249
+ return self
250
+
251
+ def __exit__(self, *_: Any) -> None:
252
+ self.close()
253
+
254
+ def __del__(self) -> None: # pragma: no cover - best effort
255
+ try:
256
+ self.close()
257
+ except Exception:
258
+ pass
259
+
260
+
261
+ class Ja3:
262
+ """Loaded ``libja3-client`` with a small requests-shaped surface."""
263
+
264
+ def __init__(self, library_path: str | os.PathLike[str] | None = None,
265
+ search_paths: Iterable[str] = (), check_abi: bool = True):
266
+ self.path = self._locate(library_path, search_paths)
267
+ self._lib = self._load(self.path)
268
+
269
+ if check_abi:
270
+ envelope = self._call("ja3_version", None)
271
+ reported = envelope.get("abi")
272
+ if reported is not None and reported != ABI_VERSION:
273
+ raise Ja3Error(
274
+ f"{self.path} speaks ABI {reported}, this binding expects {ABI_VERSION}",
275
+ error_code=reported or 7,
276
+ )
277
+
278
+ self._version = envelope.get("version", "unknown")
279
+
280
+ # -- loading -------------------------------------------------------------
281
+
282
+ @staticmethod
283
+ def _locate(library_path: str | os.PathLike[str] | None,
284
+ search_paths: Iterable[str]) -> str:
285
+ env_path = os.environ.get("JA3_CLIENT_LIB")
286
+ candidates: list[str] = []
287
+
288
+ for explicit in (library_path, env_path):
289
+ if explicit:
290
+ candidates.append(os.fspath(explicit))
291
+
292
+ # JA3_CLIENT_LIB means "this file and nothing else" (cffi/PACKAGING.md), so a
293
+ # path that is not there has to stop here: falling through to whatever else is on
294
+ # disk turns a typo in CI into a fingerprint that quietly comes from another
295
+ # version, and that is the worst kind of bug this library can have.
296
+ if env_path and not os.path.isfile(env_path):
297
+ raise Ja3Error(
298
+ f"JA3_CLIENT_LIB points at {env_path}, which is not a file", error_code=1
299
+ )
300
+
301
+ here = os.path.dirname(os.path.abspath(__file__))
302
+ root = os.path.dirname(os.path.dirname(here)) # bindings/python -> cffi
303
+ build_dirs = [
304
+ os.path.join(root, "build"),
305
+ os.path.join(root, "build", "release"),
306
+ os.path.join(root, "build", _sysname()),
307
+ os.path.join(root, "dist"),
308
+ here,
309
+ os.getcwd(),
310
+ os.path.join(os.getcwd(), "build"),
311
+ ]
312
+ build_dirs.extend(os.path.abspath(p) for p in search_paths)
313
+
314
+ for directory in build_dirs:
315
+ for name in _default_library_names():
316
+ candidates.append(os.path.join(directory, name))
317
+
318
+ seen: set[str] = set()
319
+ tried: list[str] = []
320
+
321
+ for candidate in candidates:
322
+ if candidate in seen:
323
+ continue
324
+
325
+ seen.add(candidate)
326
+ tried.append(candidate)
327
+
328
+ if os.path.isfile(candidate):
329
+ return candidate
330
+
331
+ # Nothing on disk. Ask the installer module, which owns the shared cache
332
+ # (~/.cache/ja3-client/<version>/<os>-<arch>/) that the npm and NuGet packages
333
+ # fill, and can fetch this version's archive. The import is lazy and guarded:
334
+ # the release archives ship ja3_client.py alone, and there the module really is
335
+ # absent, so the error below is the right answer. An explicit JA3_CLIENT_LIB that
336
+ # does not exist must not fall through to some other library (cffi/PACKAGING.md),
337
+ # which is why this whole branch is skipped when one was set.
338
+ if not env_path:
339
+ found = _through_installer()
340
+
341
+ if found:
342
+ return found
343
+
344
+ raise Ja3Error(
345
+ "libja3-client not found. Build it with `make` in cffi/, then point "
346
+ "JA3_CLIENT_LIB at the file. Looked at:\n " + "\n ".join(tried[:12]),
347
+ error_code=1,
348
+ )
349
+
350
+ @staticmethod
351
+ def _load(path: str) -> ctypes.CDLL:
352
+ lib = ctypes.CDLL(path)
353
+
354
+ cstr = ctypes.c_char_p
355
+ u64 = ctypes.c_ulonglong
356
+ ptr = ctypes.c_void_p
357
+
358
+ lib.ja3_version.argtypes = []
359
+ lib.ja3_request.argtypes = [cstr]
360
+ lib.ja3_session_new.argtypes = [cstr]
361
+ lib.ja3_session_request.argtypes = [u64, cstr]
362
+ lib.ja3_session_close.argtypes = [u64]
363
+ lib.ja3_free.argtypes = [ptr]
364
+
365
+ # Every JSON-returning export gives back malloc'd memory the caller owns.
366
+ # c_void_p (not c_char_p) keeps the pointer so ja3_free can release it.
367
+ for name in ("ja3_version", "ja3_request", "ja3_session_new",
368
+ "ja3_session_request", "ja3_session_close"):
369
+ getattr(lib, name).restype = ptr
370
+
371
+ lib.ja3_free.restype = None
372
+
373
+ return lib
374
+
375
+ # -- calling -------------------------------------------------------------
376
+
377
+ def _call(self, export: str, payload: Mapping[str, Any] | None,
378
+ *extra: Any) -> dict[str, Any]:
379
+ args: list[Any] = list(extra)
380
+
381
+ if payload is not None:
382
+ args.append(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
383
+
384
+ raw = getattr(self._lib, export)(*args)
385
+
386
+ if not raw:
387
+ raise Ja3Error(f"{export} returned a null pointer", error_code=7)
388
+
389
+ try:
390
+ text = ctypes.string_at(raw).decode("utf-8")
391
+ finally:
392
+ self._lib.ja3_free(raw)
393
+
394
+ envelope = json.loads(text)
395
+
396
+ if envelope.get("error_code", 0) not in (0,):
397
+ raise Ja3Error(envelope.get("error") or ERROR_CODES.get(envelope["error_code"], "error"),
398
+ error_code=envelope["error_code"], payload=envelope)
399
+
400
+ return envelope
401
+
402
+ # -- public API ----------------------------------------------------------
403
+
404
+ def _session_request(self, handle: int, payload: Mapping[str, Any]) -> Response:
405
+ envelope = self._call("ja3_session_request", payload, ctypes.c_ulonglong(handle))
406
+
407
+ return Response(envelope)
408
+
409
+ def _session_close(self, handle: int) -> None:
410
+ self._call("ja3_session_close", None, ctypes.c_ulonglong(handle))
411
+
412
+ def version(self) -> str:
413
+ return getattr(self, "_version", "unknown")
414
+
415
+ def request(self, url: str, *, method: str = "GET",
416
+ headers: Mapping[str, Any] | None = None,
417
+ body: Any = None, **options: Any) -> Response:
418
+ """One-shot request: builds a client, uses it once, drops idle connections.
419
+
420
+ Keyword arguments map onto the payload fields in cffi/README.md, e.g.
421
+ ``impersonate="chrome_133"``, ``proxy="http://127.0.0.1:7890"``,
422
+ ``with_http3=False``, ``timeout=20``.
423
+ """
424
+ payload = _build_payload(url, method, headers, body, options)
425
+
426
+ return Response(self._call("ja3_request", payload))
427
+
428
+ def get(self, url: str, **options: Any) -> Response:
429
+ return self.request(url, method="GET", **options)
430
+
431
+ def post(self, url: str, body: Any = None, **options: Any) -> Response:
432
+ return self.request(url, method="POST", body=body, **options)
433
+
434
+ def session(self, **options: Any) -> Session:
435
+ """Open a persistent client and return its handle wrapper.
436
+
437
+ Call ``session.close()`` (or use it as a context manager) - the library
438
+ keeps the connection pool and cookie jar alive until then.
439
+ """
440
+ payload = {k: v for k, v in _drop_none(options).items()}
441
+ envelope = self._call("ja3_session_new", payload)
442
+
443
+ return Session(self, envelope["session"], payload)
444
+
445
+ def close(self) -> None:
446
+ """Nothing to release on the Python side; kept for symmetry."""
447
+ return None
448
+
449
+
450
+ def _sysname() -> str:
451
+ return {"Darwin": "darwin", "Windows": "windows", "Linux": "linux"}[platform.system()]
452
+
453
+
454
+ def _drop_none(options: Mapping[str, Any]) -> dict[str, Any]:
455
+ return {key: value for key, value in options.items() if value is not None}
456
+
457
+
458
+ def _encode_body(body: Any) -> dict[str, str]:
459
+ if body is None:
460
+ return {}
461
+
462
+ if isinstance(body, bytes):
463
+ return {"body_base64": base64.b64encode(body).decode("ascii")}
464
+
465
+ if isinstance(body, str):
466
+ try:
467
+ body.encode("utf-8")
468
+ except UnicodeEncodeError: # pragma: no cover - lone surrogates
469
+ return {"body_base64": base64.b64encode(body.encode("utf-8", "surrogatepass")).decode("ascii")}
470
+
471
+ return {"body": body}
472
+
473
+ if isinstance(body, bool):
474
+ return {"body": "true" if body else "false"}
475
+
476
+ if isinstance(body, (int, float)):
477
+ return {"body": str(body)}
478
+
479
+ return {"body": json.dumps(body, ensure_ascii=False)}
480
+
481
+
482
+ def _build_payload(url: str, method: str, headers: Mapping[str, Any] | None,
483
+ body: Any, options: Mapping[str, Any]) -> dict[str, Any]:
484
+ payload: dict[str, Any] = {"url": url, "method": method.upper()}
485
+
486
+ if headers is not None:
487
+ payload["headers"] = dict(headers)
488
+
489
+ payload.update(_encode_body(body))
490
+ payload.update(_drop_none(options))
491
+
492
+ return payload
493
+
494
+
495
+ _default: Ja3 | None = None
496
+
497
+
498
+ def _lazy_default() -> Ja3:
499
+ global _default
500
+
501
+ if _default is None:
502
+ _default = Ja3()
503
+
504
+ return _default
505
+
506
+
507
+ def request(url: str, **options: Any) -> Response:
508
+ """Module-level shortcut using a lazily loaded, process-wide library handle."""
509
+ return _lazy_default().request(url, **options)
510
+
511
+
512
+ def get(url: str, **options: Any) -> Response:
513
+ return _lazy_default().get(url, **options)
514
+
515
+
516
+ def post(url: str, body: Any = None, **options: Any) -> Response:
517
+ return _lazy_default().post(url, body=body, **options)
518
+
519
+
520
+ def session(**options: Any) -> Session:
521
+ return _lazy_default().session(**options)
522
+
523
+
524
+ def version() -> str:
525
+ return _lazy_default().version()
526
+
ja3_client_binaries.py ADDED
@@ -0,0 +1,508 @@
1
+ """Install-time resolver for the ja3-client native library.
2
+
3
+ The PyPI distribution of ``ja3-client`` ships this module next to ``ja3_client.py``.
4
+ The binding code is portable; the Go/cgo build is not, so the library is downloaded
5
+ from the GitHub Release for the matching version and cached in a directory the Node
6
+ and C# bindings share.
7
+
8
+ from ja3_client import Ja3 # fetches on first use
9
+ python -m ja3_client_binaries ensure # or fetch it explicitly
10
+
11
+ cffi/PACKAGING.md owns the contract (asset names, SHA256SUMS.txt, cache layout, the
12
+ ``JA3_CLIENT_*`` variables); this module is the Python reading of it. Standard library
13
+ only - a resolver that needs a third-party package to fetch a package defeats its own
14
+ purpose.
15
+
16
+ A wheel cannot run code at install time, so unlike npm's postinstall this fetches on
17
+ first use. ``python -m ja3_client_binaries fetch`` is there for the people who want the
18
+ bytes on disk before the process starts.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import platform
27
+ import shutil
28
+ import sys
29
+ import tempfile
30
+ import time
31
+ import urllib.error
32
+ import urllib.request
33
+ import zipfile
34
+ from io import BytesIO
35
+ from pathlib import Path
36
+ from typing import Iterator, Optional
37
+
38
+ __all__ = [
39
+ "platform_slug",
40
+ "library_file_name",
41
+ "cache_dir",
42
+ "cache_library_path",
43
+ "resolve_version",
44
+ "asset_name",
45
+ "asset_url",
46
+ "sums_url",
47
+ "ensure_library",
48
+ "FindError",
49
+ "main",
50
+ ]
51
+
52
+ DEFAULT_REPO = "smallerqiu/ja3-client"
53
+ ABI_VERSION = 2
54
+ TIMEOUT_SECONDS = 60
55
+
56
+ #: Version of the library this package installs. CI stamps it from the release tag at
57
+ #: build time; a hand-built wheel keeps the last published number so the resolver asks
58
+ #: for an archive that actually exists.
59
+ PACKAGE_VERSION = "1.2.5"
60
+
61
+ _SUPPORTED_SLUGS = (
62
+ "darwin-amd64", "darwin-arm64",
63
+ "linux-amd64", "linux-arm64", "linux-armv7", "linux-386",
64
+ "windows-amd64", "windows-386",
65
+ )
66
+
67
+
68
+ def _flag(name: str) -> bool:
69
+ """Truthy for the env switches: 1/true/yes, in any case; 0/false/no/off are not."""
70
+ value = os.environ.get(name)
71
+
72
+ if value is None or value == "":
73
+ return False
74
+
75
+ return value.strip().lower() not in {"0", "false", "no", "off"}
76
+
77
+
78
+ def _env(name: str) -> Optional[str]:
79
+ value = os.environ.get(name)
80
+
81
+ return value if value else None
82
+
83
+
84
+ def platform_slug(system: Optional[str] = None, machine: Optional[str] = None) -> str:
85
+ """The ``<os>-<arch>`` half of a release asset name.
86
+
87
+ The arch table matches ``ja3_client._default_library_names`` so the file the
88
+ binding looks for and the file the resolver downloads are the same file, and it
89
+ matches package-release.sh, which derives the slug from the build directory plus
90
+ the architecture in the library name.
91
+ """
92
+ system = system or platform.system()
93
+ machine = (machine or platform.machine() or "").lower()
94
+
95
+ arch = {
96
+ "amd64": "amd64",
97
+ "x86_64": "amd64",
98
+ "arm64": "arm64",
99
+ "aarch64": "arm64",
100
+ "i386": "386",
101
+ "i686": "386",
102
+ "x86": "386",
103
+ }.get(machine, machine)
104
+
105
+ if arch.startswith("arm") and arch not in {"arm64"}:
106
+ # armv6l/armv7l: the release names the 32-bit build after its file, armv7.
107
+ arch = "armv7"
108
+
109
+ os_name = {"Darwin": "darwin", "Windows": "windows", "Linux": "linux"}.get(system)
110
+
111
+ if os_name is None:
112
+ raise FindError(
113
+ f"ja3-client: no release archive for system {system!r}; "
114
+ f"build the library (make -C cffi host) and set JA3_CLIENT_LIB"
115
+ )
116
+
117
+ slug = f"{os_name}-{arch}"
118
+
119
+ if slug not in _SUPPORTED_SLUGS:
120
+ raise FindError(
121
+ f"ja3-client: no release archive for {slug}; "
122
+ f"build the library (make -C cffi host) and set JA3_CLIENT_LIB"
123
+ )
124
+
125
+ return slug
126
+
127
+
128
+ def library_file_name(slug: str) -> str:
129
+ """The library file inside ``ja3-client-<version>-<slug>.zip``."""
130
+ arch = slug.split("-")[1]
131
+
132
+ if slug.startswith("darwin"):
133
+ return f"ja3_client_{arch}.dylib"
134
+
135
+ if slug.startswith("windows"):
136
+ return f"ja3_client_{arch}.dll"
137
+
138
+ return f"libja3_client_{arch}.so"
139
+
140
+
141
+ def resolve_version() -> str:
142
+ """Library version to fetch: JA3_CLIENT_VERSION, else this package's version."""
143
+ return _env("JA3_CLIENT_VERSION") or PACKAGE_VERSION
144
+
145
+
146
+ def repo() -> str:
147
+ return _env("JA3_CLIENT_REPO") or DEFAULT_REPO
148
+
149
+
150
+ def tag_for(version: str) -> str:
151
+ return version if version.startswith("v") else f"v{version}"
152
+
153
+
154
+ def base_url() -> str:
155
+ """Download root. The override exists for mirrors and for the offline installer
156
+ test (cffi/test-installers.sh serves a local dist/); it replaces everything before
157
+ ``/<tag>/<asset>``."""
158
+ return _env("JA3_CLIENT_BASE_URL") or f"https://github.com/{repo()}/releases/download"
159
+
160
+
161
+ def asset_name(version: str, slug: str) -> str:
162
+ return f"ja3-client-{version}-{slug}.zip"
163
+
164
+
165
+ def asset_url(version: str, slug: str) -> str:
166
+ return f"{base_url()}/{tag_for(version)}/{asset_name(version, slug)}"
167
+
168
+
169
+ def sums_url(version: str) -> str:
170
+ return f"{base_url()}/{tag_for(version)}/SHA256SUMS.txt"
171
+
172
+
173
+ def cache_dir() -> Path:
174
+ """Cache root shared by the npm, PyPI and NuGet bindings, so one download serves
175
+ all three on the same machine."""
176
+ explicit = _env("JA3_CLIENT_CACHE_DIR")
177
+
178
+ if explicit:
179
+ return Path(explicit)
180
+
181
+ xdg = _env("XDG_CACHE_HOME")
182
+
183
+ if xdg:
184
+ return Path(xdg) / "ja3-client"
185
+
186
+ home = Path.home()
187
+ system = platform.system()
188
+
189
+ if system == "Windows":
190
+ return Path(_env("LOCALAPPDATA") or home / "AppData" / "Local") / "ja3-client"
191
+
192
+ if system == "Darwin":
193
+ return home / "Library" / "Caches" / "ja3-client"
194
+
195
+ return home / ".cache" / "ja3-client"
196
+
197
+
198
+ def cache_library_path(version: str, slug: str) -> Path:
199
+ return cache_dir() / version / slug / library_file_name(slug)
200
+
201
+
202
+ class FindError(RuntimeError):
203
+ """Nothing on disk and nothing downloadable: the caller gets the manual recipe."""
204
+
205
+
206
+ class ChecksumError(FindError):
207
+ """The archive does not match SHA256SUMS.txt - never unpack it."""
208
+
209
+
210
+ def download(url: str, timeout: float = TIMEOUT_SECONDS, attempts: int = 2) -> bytes:
211
+ """Fetch a URL into bytes.
212
+
213
+ urllib follows the 302 that GitHub answers at /releases/download/ for us, and that
214
+ redirect to a signed host is what makes the bytes tamper-evident in transit, so
215
+ do not "harden" this by disabling redirects. One retry, because a CDN hiccup should
216
+ not look like a broken package; an HTTP 4xx is a real answer and is not retried.
217
+ """
218
+ last: Optional[BaseException] = None
219
+
220
+ for attempt in range(attempts):
221
+ try:
222
+ with urllib.request.urlopen(url, timeout=timeout) as response:
223
+ code = getattr(response, "status", 200) or 200
224
+
225
+ if code != 200:
226
+ raise FindError(f"HTTP {code} for {url}")
227
+
228
+ return response.read()
229
+ except urllib.error.HTTPError as err:
230
+ if 400 <= err.code < 500:
231
+ raise FindError(f"HTTP {err.code} for {url}") from err
232
+
233
+ last = err
234
+ except (urllib.error.URLError, TimeoutError, OSError) as err:
235
+ last = err
236
+
237
+ if attempt + 1 < attempts:
238
+ time.sleep(0.5)
239
+
240
+ raise FindError(f"could not download {url}: {last}")
241
+
242
+
243
+ def sha256_hex(data: bytes) -> str:
244
+ return hashlib.sha256(data).hexdigest()
245
+
246
+
247
+ def expected_sha(sums_text: str, asset: str) -> str:
248
+ """This archive's line from SHA256SUMS.txt (``<sha> <name>``).
249
+
250
+ Exact name match: linux-arm64 and linux-armv7 are one character apart, and a prefix
251
+ match would report a checksum failure on a perfectly good download. A sums file
252
+ without a line for this asset means an incomplete release, which is a different
253
+ problem from a corrupt download and has to be said differently.
254
+ """
255
+ for line in sums_text.splitlines():
256
+ if not line.strip():
257
+ continue
258
+
259
+ sha, name = line[:64].strip(), line[64:].strip().lstrip("*").strip()
260
+
261
+ if name == asset:
262
+ if len(sha) != 64 or any(c not in "0123456789abcdefABCDEF" for c in sha):
263
+ raise ChecksumError(f"SHA256SUMS.txt has a malformed hash for {asset}")
264
+
265
+ return sha.lower()
266
+
267
+ raise ChecksumError(f"SHA256SUMS.txt has no line for {asset} - the release is incomplete")
268
+
269
+
270
+ def _extract_member(data: bytes, base_name: str, target: Path) -> None:
271
+ """Write one member of the archive, by base name, into ``target``.
272
+
273
+ Only the base name is ever used, so a member named ``../../.ssh/x`` cannot escape
274
+ the cache directory even though the bytes came off the network.
275
+ """
276
+ with zipfile.ZipFile(BytesIO(data)) as archive:
277
+ for info in archive.infolist():
278
+ if info.is_dir() or Path(info.filename).name != base_name:
279
+ continue
280
+
281
+ target.write_bytes(archive.read(info))
282
+
283
+ return
284
+
285
+ raise ChecksumError(f"the archive has no {base_name} inside it")
286
+
287
+
288
+ def find_cached_library(names: Optional[Iterator[str]] = None) -> Optional[str]:
289
+ """A library already in the cache, newest version first.
290
+
291
+ The cache outlives upgrades: picking the oldest directory would run a 1.2.5 binding
292
+ against a 1.2.3 library and the fingerprint mismatch would be blamed on the profiles.
293
+ """
294
+ root = cache_dir()
295
+
296
+ if not root.is_dir():
297
+ return None
298
+
299
+ wanted = list(names) if names else list(_default_names())
300
+
301
+ for version_dir in sorted((p for p in root.iterdir() if p.is_dir()), reverse=True):
302
+ for slug_dir in sorted((p for p in version_dir.iterdir() if p.is_dir()), reverse=True):
303
+ for name in wanted:
304
+ candidate = slug_dir / name
305
+
306
+ if candidate.is_file():
307
+ return str(candidate)
308
+
309
+ return None
310
+
311
+
312
+ def _default_names() -> Iterator[str]:
313
+ """Library file names this machine could use, matching ja3_client's own list."""
314
+ slug = platform_slug()
315
+ arch = slug.split("-")[1]
316
+
317
+ yield library_file_name(slug)
318
+
319
+ if arch == "armv7":
320
+ yield "libja3_client_arm.so"
321
+
322
+
323
+ def _failure(slug: str, version: str, url: str, reason: str) -> FindError:
324
+ """The three lines PACKAGING.md fixes as the shape of every fetch failure: what was
325
+ wanted, where it was looked for, and how to do it by hand."""
326
+ return FindError(
327
+ f"ja3-client: could not fetch the native library for {slug} ({tag_for(version)}).\n"
328
+ f" tried: {url}\n"
329
+ " offline? build it (make -C cffi host), or unzip a release archive and "
330
+ f"set JA3_CLIENT_LIB=/path/to/lib\n reason: {reason}"
331
+ )
332
+
333
+
334
+ def ensure_library(version: Optional[str] = None, slug: Optional[str] = None,
335
+ log=None) -> str:
336
+ """Return the absolute path of a usable library, downloading it if needed.
337
+
338
+ Raises FindError with the manual recipe when nothing is available; never touches the
339
+ network when JA3_CLIENT_LIB or JA3_CLIENT_OFFLINE says not to.
340
+ """
341
+ explicit = _env("JA3_CLIENT_LIB")
342
+
343
+ if explicit:
344
+ if not os.path.isfile(explicit):
345
+ raise FindError(f"JA3_CLIENT_LIB points at {explicit}, which is not a file")
346
+
347
+ return os.path.abspath(explicit)
348
+
349
+ version = version or resolve_version()
350
+ slug = slug or platform_slug()
351
+ target = cache_library_path(version, slug)
352
+
353
+ if target.is_file():
354
+ if log:
355
+ log(f"ja3-client: cached {slug} {version}")
356
+
357
+ return str(target)
358
+
359
+ url = asset_url(version, slug)
360
+
361
+ if _flag("JA3_CLIENT_OFFLINE"):
362
+ raise _failure(slug, version, url, "JA3_CLIENT_OFFLINE is set")
363
+
364
+ asset = asset_name(version, slug)
365
+
366
+ # Every network step below reports through _failure(), because a bare
367
+ # "HTTP 404 for .../SHA256SUMS.txt" is a dead end: the sums file is the one missing
368
+ # asset that means "this release was never finished", and the user still needs the
369
+ # recipe. A sums line that does not match the archive stays a ChecksumError, which
370
+ # is a different problem and says so.
371
+ def fetch(url: str) -> bytes:
372
+ try:
373
+ return download(url)
374
+ except Exception as err:
375
+ raise _failure(slug, version, url, str(err)) from err
376
+
377
+ sums = fetch(sums_url(version)).decode("utf-8", "replace")
378
+ want = expected_sha(sums, asset)
379
+ archive = fetch(url)
380
+
381
+ got = sha256_hex(archive)
382
+
383
+ if got != want:
384
+ raise _failure(slug, version, url,
385
+ f"sha256 is {got}, SHA256SUMS.txt says {want}")
386
+
387
+ name = library_file_name(slug)
388
+
389
+ # Extract through a temp directory and one rename: an install killed halfway must
390
+ # not leave a truncated library that ctypes accepts and then crashes inside.
391
+ target.parent.mkdir(parents=True, exist_ok=True)
392
+ staging = Path(tempfile.mkdtemp(dir=str(target.parent), prefix=".tmp-"))
393
+
394
+ try:
395
+ _extract_member(archive, name, staging / name)
396
+ (staging / "install.json").write_text(
397
+ json.dumps(
398
+ {
399
+ "version": version,
400
+ "asset": asset,
401
+ "url": url,
402
+ "sha256": want,
403
+ "abi": ABI_VERSION,
404
+ "installed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
405
+ "installed_by": "pip",
406
+ },
407
+ indent=2,
408
+ )
409
+ + "\n",
410
+ encoding="utf-8",
411
+ )
412
+
413
+ for staged in (name, "install.json"):
414
+ dest = target.parent / staged
415
+
416
+ if dest.exists():
417
+ dest.unlink()
418
+
419
+ shutil.move(str(staging / staged), str(dest))
420
+
421
+ # ctypes does not care about the mode, but smoke.py runs the file directly on
422
+ # some systems and a 0600 library is a confusing thing to debug.
423
+ target.chmod(0o755)
424
+ finally:
425
+ shutil.rmtree(staging, ignore_errors=True)
426
+
427
+ if log:
428
+ log(f"ja3-client: {slug} {version} -> {target}")
429
+
430
+ return str(target)
431
+
432
+
433
+ def status() -> dict:
434
+ version = resolve_version()
435
+
436
+ try:
437
+ slug = platform_slug()
438
+ path = str(cache_library_path(version, slug))
439
+ cached = Path(path).is_file()
440
+ except FindError as err:
441
+ slug, path, cached = str(err), "", False
442
+
443
+ return {
444
+ "version": version,
445
+ "package_version": PACKAGE_VERSION,
446
+ "slug": slug,
447
+ "repo": repo(),
448
+ "base_url": base_url(),
449
+ "cache_dir": str(cache_dir()),
450
+ "library_path": path,
451
+ "cached": cached,
452
+ "offline": _flag("JA3_CLIENT_OFFLINE"),
453
+ "explicit_lib": _env("JA3_CLIENT_LIB") or "",
454
+ }
455
+
456
+
457
+ _COMMANDS = ("print-url", "ensure", "fetch", "path", "status")
458
+
459
+
460
+ def main(argv: Optional[list[str]] = None) -> int:
461
+ argv = list(sys.argv[1:] if argv is None else argv)
462
+ command = argv[0] if argv else "status"
463
+
464
+ if command in ("-h", "--help"):
465
+ print(f"usage: python -m ja3_client_binaries <{'|'.join(_COMMANDS)}>")
466
+
467
+ return 0
468
+
469
+ try:
470
+ if command == "print-url":
471
+ print(asset_url(resolve_version(), platform_slug()))
472
+
473
+ return 0
474
+
475
+ if command in ("ensure", "fetch"):
476
+ print(ensure_library(log=lambda line: print(line, file=sys.stderr)))
477
+
478
+ return 0
479
+
480
+ if command == "path":
481
+ found = cache_library_path(resolve_version(), platform_slug())
482
+
483
+ if not found.is_file():
484
+ print(f"ja3-client: no cached library at {found}", file=sys.stderr)
485
+
486
+ return 1
487
+
488
+ print(found)
489
+
490
+ return 0
491
+
492
+ if command == "status":
493
+ for key, value in status().items():
494
+ print(f"{key}={value}")
495
+
496
+ return 0
497
+ except FindError as err:
498
+ print(err, file=sys.stderr)
499
+
500
+ return 1
501
+
502
+ print(f"unknown command '{command}' ({'|'.join(_COMMANDS)})", file=sys.stderr)
503
+
504
+ return 2
505
+
506
+
507
+ if __name__ == "__main__":
508
+ sys.exit(main())