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.
- ja3_client-1.2.5.dist-info/METADATA +90 -0
- ja3_client-1.2.5.dist-info/RECORD +8 -0
- ja3_client-1.2.5.dist-info/WHEEL +5 -0
- ja3_client-1.2.5.dist-info/entry_points.txt +2 -0
- ja3_client-1.2.5.dist-info/licenses/LICENSE +21 -0
- ja3_client-1.2.5.dist-info/top_level.txt +2 -0
- ja3_client.py +526 -0
- ja3_client_binaries.py +508 -0
|
@@ -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,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.
|
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())
|