TLSSession.set_cookie/get_cookies/clear_cookies crashed with AttributeError on both backends (they reached into self.session.cookie_jar, which curl_cffi and noble_tls sessions don't have); both backends now route the mutable cookie api through their own requests-style session.cookies store. Noble.raw_request now requests is_byte_response=True and decodes the resulting base64 data-URI body, since noble_tls's default text response silently corrupts any binary payload (image/zip/pdf) via lossy UTF-8 decoding on the Go side. CurlCffi/Noble.create_session no longer bake session-default headers into the underlying client; baking caused clear_headers()/get_headers() to lie about what's actually still on the wire (a credential-leak divergence from the aiohttp base, which never bakes). Headers flow through aioweb's per-request merge only, matching the base's documented contract. Noble.create_session now applies the same max(1, ceil()) timeout coercion raw_request already had (extracted into a shared _noble_timeout_seconds helper) — without it, a sub-second/float session-default timeout made every request fail Go-side JSON unmarshal. README corrected: dropped the 'every aioweb feature behaves identically' overclaim re: cookies, documented the binary-body handling and the no-header-baking rationale, bumped install pins to v0.1.4. Signed-off-by: disqualifier <dev@disqualifier.me>
9.9 KiB
aioweb_tls
TLS-fingerprinting backends for aioweb.
One session class — TLSSession — takes an injected backend that swaps the HTTP
client (and thus the TLS/HTTP fingerprint), while inheriting every aioweb feature —
header overwrites, domain rewriting, ephemeral headers, proxies, retries, previews —
unchanged. The backend swaps the wire, not the session.
from aioweb_tls import TLSSession, CurlCffi, Noble # the two bundled backends
TLSSession(backend=CurlCffi(impersonate="chrome"))
TLSSession(backend=Noble(client="chrome_133"))
TLSSession(backend=MyBackend(...)) # or one you write — see "Writing your own backend"
Install
Depends on aioweb. The TLS clients are optional extras — install the backend(s)
you want; importing the package never fails because an extra is missing.
requirements.txt (pick the extra you need):
aioweb_tls[curl] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.4
aioweb_tls[noble] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.4
aioweb_tls[all] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.4
Direct:
pip install "aioweb_tls[curl] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.4"
pip install "aioweb_tls[noble] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.4"
pip install "aioweb_tls[all] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.4"
[curl]→ curl_cffi backend ·[noble]→ noble_tls backend ·[all]→ both.- No extra (
pip install aioweb_tls) is also valid: you getTLSSessionand theTLSBackendprotocol — the framework and the bring-your-own-backend path — but no bundled client.CurlCffi()/Noble()then raise a clearRuntimeErrornaming the extra to install. Use a bare install when you only need a custom backend of your own.
Constructing a backend whose client isn't installed raises that RuntimeError at
construction, never at import.
Drop the @v0.1.4 suffix from the line above to install the latest unpinned.
curl_cffi backend
from aioweb_tls import TLSSession, CurlCffi
async with TLSSession(backend=CurlCffi(impersonate="chrome"), proxies={"https": "http://..."}) as s:
resp = await s.request_with_retries("GET", "https://tls.peet.ws/api/all")
if resp: # FailureResponse is falsy
print(resp.json()["tls"]["ja3"])
CurlCffi(impersonate="chrome")sets the forged profile; override it per call by passingimpersonate=to the low-levelrequest()(which forwards**kwargsto the backend).request_with_retrieshas a fixed signature and does not accept extra backend kwargs — passingimpersonate=there raisesTypeError; set the profile on theCurlCffiinstance for the retrying path.- curl_cffi forges JA3/JA4 + HTTP/2 fingerprints via the bundled curl-impersonate binary.
noble backend
from aioweb_tls import TLSSession, Noble
async with TLSSession(backend=Noble(client="chrome_133")) as s:
await s.setup() # fetch noble's Go shared lib once (network)
resp = await s.request_with_retries("GET", "https://tls.peet.ws/api/all")
if resp:
print(resp.json()["tls"]["ja3"])
Noble(client="chrome_133")— accepts anoble_tls.Clientenum or a string name.- noble_tls downloads a Go shared library on first use.
await s.setup()fetches it once at startup; if you skip it, the first request fetches it lazily. The fetch is guarded by a lock, so even concurrent first requests download it exactly once. - Binary bodies (images, zips, PDFs, protobuf) round-trip as true bytes: noble_tls
returns response bodies as a UTF-8 JSON string by default, which mangles non-UTF-8
bytes (
U+FFFDreplacement, wrong length) even on a 200 response — the Noble backend always requestsis_byte_response=Trueand decodes the resulting base64 data-URI back into raw bytes, soresp.contentis never lossy.
Writing your own backend (the TLSBackend protocol)
aioweb_tls ships exactly two backends — CurlCffi and Noble — plus the
TLSBackend protocol for writing your own. There is no Go, remote, or other
bundled backend; anything beyond curl/noble is something you implement against the
protocol. TLSSession itself is backend-agnostic, so a backend you write injects
exactly like the built-ins (TLSSession(backend=YourBackend(...))) and inherits
every aioweb feature for free.
To add a backend, write a small object satisfying TLSBackend (see protocol.py
for the authoritative contract):
| member | required? | signature | purpose |
|---|---|---|---|
create_session |
required | (headers, timeout, **kwargs) -> session |
build the client object TLSSession stores as self.session |
raw_request |
required | async (session, method, url, **kwargs) -> aioweb.Response |
send one request; adapt the client's response into an aioweb.Response |
is_closed |
required | (session) -> bool |
whether the session is closed |
cookies_for_url |
optional | (session, url) -> dict |
cookies for preview(); defaults to {} |
set_cookie |
optional | (session, name, value, domain=None, path="/") -> None |
backs TLSSession.set_cookie(); raises NotImplementedError if absent |
get_cookies |
optional | (session) -> dict |
backs TLSSession.get_cookies(); raises NotImplementedError if absent |
clear_cookies |
optional | (session) -> None |
backs TLSSession.clear_cookies(); raises NotImplementedError if absent |
setup |
optional | async () -> None |
one-time prep (e.g. fetch a native lib); idempotent |
close |
optional | async (session) -> None |
close the session; defaults to await session.close() |
raw_request receives aioweb-shaped kwargs: the proxy is already resolved into
kwargs["proxy"], headers are merged into kwargs["headers"], and a numeric timeout
is wrapped in an aiohttp.ClientTimeout (unwrap .total).
Example — a backend you would write (not included)
The class below is illustrative template code you implement yourself, shown to
make the protocol concrete. It is not part of aioweb_tls — there is no
from aioweb_tls import GoTLSBackend. It sketches one case (talking to a local Go
TLS service over HTTP) so the shape of a conformant backend is clear:
# example: a backend YOU write to drive a local Go TLS service.
# NOT shipped by aioweb_tls — this is a TLSBackend-conformance template.
from aioweb import Response
from aioweb_tls import TLSSession
class GoTLSBackend:
"""example user-written backend: drives a local Go tls-server over HTTP"""
def __init__(self, endpoint: str):
self.endpoint = endpoint
def create_session(self, headers, timeout, **kwargs):
# build/return whatever client object raw_request will use
import aiohttp
return aiohttp.ClientSession(headers=headers)
async def raw_request(self, session, method, url, **kwargs) -> Response:
# kwargs arrive aioweb-shaped: proxy in kwargs["proxy"], headers merged,
# numeric timeout wrapped in an aiohttp.ClientTimeout (unwrap .total).
payload = {"method": method, "url": url, "headers": kwargs.get("headers", {})}
async with session.post(self.endpoint, json=payload) as r:
body = await r.json()
return Response(
status_code=body["status"], headers=body["headers"],
content=body["body"].encode(), url=url, reason=body.get("reason"),
)
def is_closed(self, session) -> bool:
return session.closed
async def close(self, session) -> None:
await session.close()
# inject your backend — same shape as CurlCffi / Noble, all aioweb features inherited
async with TLSSession(backend=GoTLSBackend("http://localhost:8080")) as s:
s.overwrite_domain("internal.local", "127.0.0.1")
resp = await s.request_with_retries("GET", "https://internal.local/x")
Inherited features work unchanged
aioweb's overwrite/domain/ephemeral/proxy/retry/preview logic operates on plain dicts and never touches the HTTP backend — only the seams do. Header overwrites, domain rewriting, ephemeral headers, proxies, retries, and previews behave identically on any backend:
async with TLSSession(backend=CurlCffi(impersonate="chrome")) as s:
s.overwrite_domain("internal.local", "127.0.0.1")
s.set_ephemeral("X-Time", lambda: str(time.time()))
s.overwrite_inject(True)
print(s.preview("GET", "https://internal.local/x").as_curl()) # reflects all of the above
Session-default headers are never baked into the underlying client (neither
CurlCffi nor Noble passes headers= to their client's constructor) — they flow
through aioweb's own per-request _default_headers merge instead. That keeps
update_headers() / clear_headers() accurate for both backends: what
get_headers() and preview() report is what actually goes out on the wire, with
no stale, already-baked value resurfacing after a clear.
The mutable cookie API — set_cookie() / get_cookies() / clear_cookies() — is
also backend-aware: CurlCffi and Noble each route it through their own client's
cookie store (both expose a requests-style session.cookies with set() /
items() / clear()), so these calls work the same way they do on the base
aioweb.ExtendedSession, not just _cookies_for_url() (used by preview()).
Honesty note
TLS fingerprinting changes one layer — the TLS/HTTP fingerprint. It does not by itself defeat modern bot protection: behavioral analysis, captchas, and JS challenges are separate signals. Use this as one component, not a complete anti-bot solution.
Versioning
Releases are tagged vX.Y.Z. The install line above pins a release; drop the @vX.Y.Z suffix to install the latest unpinned. Pin deliberately for reproducible installs.