Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- `local_address` parameter to bind outgoing TCP connections to a specific local IP for outbound interface/IP selection (#65)

### Fixed
- `Do()` dropped `ServerName`, `TLS13AutoRetry`, and `DisableGrease` request fields when constructing the underlying request (#65)
- `TLS13AutoRetry` proactive upgrade corrupted JA3 `supported_groups`; the original JA3 ordering is now preserved across the retry (#65)
- `dispatchSSEAsync` could enter an infinite loop on stream cancel/EOF (#65)
2 changes: 2 additions & 0 deletions cycletls/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"default_disable_grease": "disable_grease",
"default_user_agent": "user_agent",
"default_proxy": "proxy",
"default_local_address": "local_address",
"default_timeout": "timeout",
"default_enable_connection_reuse": "enable_connection_reuse",
"default_insecure_skip_verify": "insecure_skip_verify",
Expand Down Expand Up @@ -47,6 +48,7 @@ def _validate_config(name: str, value: Any) -> None:
"default_disable_grease": lambda v: isinstance(v, bool),
"default_order_headers_as_provided": lambda v: isinstance(v, bool),
"default_proxy": lambda v: v is None or isinstance(v, str),
"default_local_address": lambda v: v is None or isinstance(v, str),
"default_ja3": lambda v: v is None or isinstance(v, str),
"default_ja4r": lambda v: v is None or isinstance(v, str),
"default_user_agent": lambda v: v is None or isinstance(v, str),
Expand Down
12 changes: 11 additions & 1 deletion cycletls/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,17 @@ def request(
If a string, looks up the profile in FingerprintRegistry.
Applies the profile's ja3, user_agent, header_order, etc.
auth: (username, password) tuple for HTTP Basic authentication
**kwargs: Additional CycleTLS options (headers, cookies, proxy, etc.)
**kwargs: Additional CycleTLS options. Notable options include:
- headers: dict of extra HTTP headers
- cookies: list of cookie dicts
- ja3: JA3 TLS fingerprint string
- user_agent: User-Agent header value
- proxy: proxy URL (e.g. "http://user:pass@host:port")
- local_address: local IP address to bind the outgoing TCP
connection to (useful on multi-homed hosts)
- timeout: request timeout in seconds
- insecure_skip_verify: skip TLS certificate verification
- enable_connection_reuse: reuse persistent connections (default True)

Returns:
Response: Response object with status, headers, body, cookies, etc.
Expand Down
3 changes: 3 additions & 0 deletions cycletls/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ class Request:
# Connection options
user_agent: str = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0"
proxy: str = ""
local_address: Optional[str] = None # Bind outgoing TCP connections to this local IP address
cookies: Optional[List[Cookie]] = None
timeout: Union[int, float] = 6 # Timeout in seconds (floats are rounded up to nearest integer)
disable_redirect: bool = False
Expand Down Expand Up @@ -226,6 +227,8 @@ def to_dict(self) -> dict:
result["quicFingerprint"] = self.quic_fingerprint
if self.server_name is not None:
result["serverName"] = self.server_name
if self.local_address is not None:
result["localAddress"] = self.local_address
if self.protocol is not None:
result["protocol"] = self.protocol.value
if self.cookies is not None:
Expand Down
45 changes: 33 additions & 12 deletions golang/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
fhttp "github.com/Danny-Dasilva/fhttp"
"hash/crc32"
"hash/fnv"
"net"
"sync"
"time"

Expand Down Expand Up @@ -94,6 +95,16 @@ type Browser struct {
ForceHTTP1 bool
ForceHTTP3 bool

// LocalAddress, when non-empty, is the local IP the kernel binds the
// outbound TCP socket to (via net.Dialer.LocalAddr). When a proxy is in
// the path, the bind applies to the client->proxy hop only — the proxy
// opens its own socket to the destination, so the destination server
// never observes this IP. Treat this as routing/interface control, NOT
// as an anonymizer against the proxy itself: the proxy host always sees
// LocalAddress (and any on-path observer between client and proxy does
// too).
LocalAddress string

// DisableKeepAlives, when true, disables HTTP keep-alives at the
// inner http.Transport layer for every request that uses this Browser.
// Set this when the caller passes enable_connection_reuse=false from
Expand Down Expand Up @@ -207,22 +218,23 @@ func NewTransportWithProxy(ja3 string, useragent string, proxy proxy.ContextDial
}

// generateClientKey creates a unique key for client pooling based on browser configuration
func generateClientKey(browser Browser, timeout int, disableRedirect bool, proxyURL string) string {
func generateClientKey(browser Browser, timeout int, disableRedirect bool, proxyURL string, localAddress string) string {
// Create cookie signature for the key
cookieStr := ""
for _, cookie := range browser.Cookies {
cookieStr += fmt.Sprintf("|cookie:%s=%s", cookie.Name, cookie.Value)
}

// Create a hash of the configuration that affects connection behavior
configStr := fmt.Sprintf("ja3:%s|ja4r:%s|http2:%s|quic:%s|ua:%s|sni:%s|proxy:%s|timeout:%d|redirect:%t|skipverify:%t|forcehttp1:%t|forcehttp3:%t%s",
configStr := fmt.Sprintf("ja3:%s|ja4r:%s|http2:%s|quic:%s|ua:%s|sni:%s|proxy:%s|local:%s|timeout:%d|redirect:%t|skipverify:%t|forcehttp1:%t|forcehttp3:%t%s",
browser.JA3,
browser.JA4r,
browser.HTTP2Fingerprint,
browser.QUICFingerprint,
browser.UserAgent,
browser.ServerName,
proxyURL,
localAddress,
timeout,
disableRedirect,
browser.InsecureSkipVerify,
Expand Down Expand Up @@ -259,20 +271,20 @@ func generateClientKey(browser Browser, timeout int, disableRedirect bool, proxy
// http.Transport would still pool idle conns per address — see the badssl
// triage report at .claude/cache/agents/source-tracer/output.md for the
// failure mode this prevents.
func getOrCreateClient(browser Browser, timeout int, disableRedirect bool, userAgent string, enableConnectionReuse bool, proxyURL ...string) (fhttp.Client, error) {
func getOrCreateClient(browser Browser, timeout int, disableRedirect bool, userAgent string, enableConnectionReuse bool, localAddress string, proxyURL ...string) (fhttp.Client, error) {
// If connection reuse is disabled, always create a new client and tell
// the inner http.Transport to disable keep-alives.
if !enableConnectionReuse {
browser.DisableKeepAlives = true
return createNewClient(browser, timeout, disableRedirect, userAgent, proxyURL...)
return createNewClient(browser, timeout, disableRedirect, userAgent, localAddress, proxyURL...)
}

proxyStr := ""
if len(proxyURL) > 0 {
proxyStr = proxyURL[0]
}

clientKey := generateClientKey(browser, timeout, disableRedirect, proxyStr)
clientKey := generateClientKey(browser, timeout, disableRedirect, proxyStr, localAddress)

// Get the appropriate shard for this client key
shard := globalPool.getShard(clientKey)
Expand All @@ -299,7 +311,7 @@ func getOrCreateClient(browser Browser, timeout int, disableRedirect bool, userA
}

// Create new client
client, err := createNewClient(browser, timeout, disableRedirect, userAgent, proxyURL...)
client, err := createNewClient(browser, timeout, disableRedirect, userAgent, localAddress, proxyURL...)
if err != nil {
return fhttp.Client{}, err
}
Expand All @@ -316,17 +328,26 @@ func getOrCreateClient(browser Browser, timeout int, disableRedirect bool, userA
}

// createNewClient creates a new HTTP client (internal function)
func createNewClient(browser Browser, timeout int, disableRedirect bool, userAgent string, proxyURL ...string) (fhttp.Client, error) {
func createNewClient(browser Browser, timeout int, disableRedirect bool, userAgent string, localAddress string, proxyURL ...string) (fhttp.Client, error) {
// Stamp localAddress onto the Browser so downstream consumers (the
// roundTripper, in particular the HTTP/3 UDP listener) can honor it.
browser.LocalAddress = localAddress
var dialer proxy.ContextDialer
if len(proxyURL) > 0 && len(proxyURL[0]) > 0 {
var err error
dialer, err = newConnectDialer(proxyURL[0], userAgent)
dialer, err = newConnectDialer(proxyURL[0], userAgent, localAddress)
if err != nil {
return fhttp.Client{
Timeout: time.Duration(timeout) * time.Second,
CheckRedirect: disabledRedirect,
}, err
}
} else if localAddress != "" {
ip := net.ParseIP(localAddress)
if ip == nil {
return fhttp.Client{}, fmt.Errorf("invalid local_address %q: not a valid IP address", localAddress)
}
dialer = &net.Dialer{LocalAddr: &net.TCPAddr{IP: ip}}
} else {
dialer = proxy.Direct
}
Expand Down Expand Up @@ -373,12 +394,12 @@ func clearAllConnections() {
// newClient creates a new http client (backward compatibility - defaults to no connection reuse)
func newClient(browser Browser, timeout int, disableRedirect bool, UserAgent string, proxyURL ...string) (fhttp.Client, error) {
// Backward compatibility: default to no connection reuse for existing code
return getOrCreateClient(browser, timeout, disableRedirect, UserAgent, false, proxyURL...)
return getOrCreateClient(browser, timeout, disableRedirect, UserAgent, false, "", proxyURL...)
}

// newClientWithReuse creates a new http client with configurable connection reuse
func newClientWithReuse(browser Browser, timeout int, disableRedirect bool, UserAgent string, enableConnectionReuse bool, proxyURL ...string) (fhttp.Client, error) {
return getOrCreateClient(browser, timeout, disableRedirect, UserAgent, enableConnectionReuse, proxyURL...)
func newClientWithReuse(browser Browser, timeout int, disableRedirect bool, UserAgent string, enableConnectionReuse bool, localAddress string, proxyURL ...string) (fhttp.Client, error) {
return getOrCreateClient(browser, timeout, disableRedirect, UserAgent, enableConnectionReuse, localAddress, proxyURL...)
}

// WebSocketConnect establishes a WebSocket connection
Expand Down Expand Up @@ -439,7 +460,7 @@ func (browser Browser) WebSocketConnect(ctx context.Context, urlStr string) (*we
// SSEConnect establishes an SSE connection
func (browser Browser) SSEConnect(ctx context.Context, urlStr string) (*SSEResponse, error) {
// Create HTTP client with connection reuse enabled
httpClient, err := newClientWithReuse(browser, 30, false, browser.UserAgent, true)
httpClient, err := newClientWithReuse(browser, 30, false, browser.UserAgent, true, "")
if err != nil {
return nil, err
}
Expand Down
Loading
Loading