Documentation
¶
Overview ¶
Package unblock is an HTTP client for Go that looks like a browser at the network layer (Chrome-like TLS ClientHello and HTTP/2 fingerprint) and knows when it has been blocked.
Three things are handled:
- TLS/JA3 and HTTP/2 fingerprints — via bogdanfinn/tls-client, so WAF rules based on fingerprints do not reject the request.
- Automatic HTTP/1.1 fallback when a server rejects HTTP/2 (RST_STREAM), reusing the same TLS fingerprint.
- Block classification — Cloudflare challenges, errors 1009/1010/1015/1020, and third-party WAFs are told apart from one another, because each needs a different follow-up action.
This package does NOT execute JavaScript. A Cloudflare challenge that requires JS cannot be bypassed here; Classify only reports that this is what happened.
Index ¶
- Constants
- Variables
- func DefaultHeaders(profile string) map[string]string
- func HostOf(raw string) string
- type Attempt
- type AttemptResult
- type Block
- type BlockedError
- type Client
- func (c *Client) CloseIdleConnections()
- func (c *Client) Do(ctx context.Context, method, url string) (*Response, error)
- func (c *Client) Get(ctx context.Context, url string) (*Response, error)
- func (c *Client) Head(ctx context.Context, url string) (*Response, error)
- func (c *Client) Post(ctx context.Context, url, contentType string, body []byte) (*Response, error)
- type Cooldown
- type CooldownError
- type Fetcher
- type Ladder
- func (l *Ladder) CloseIdleConnections()
- func (l *Ladder) Get(ctx context.Context, url string) (*Response, error)
- func (l *Ladder) Head(ctx context.Context, url string) (*Response, error)
- func (l *Ladder) Last() []AttemptResult
- func (l *Ladder) Post(ctx context.Context, url, contentType string, body []byte) (*Response, error)
- func (l *Ladder) Winner() Fetcher
- func (l *Ladder) WithCooldown(c *Cooldown) *Ladder
- func (l *Ladder) WithFetcherFactory(f func(Attempt) Fetcher) *Ladder
- func (l *Ladder) WithMaxAttempts(n int) *Ladder
- type Options
- type Poster
- type Response
Examples ¶
Constants ¶
const DefaultCooldownWindow = 6 * time.Hour
DefaultCooldownWindow is used when NewCooldown receives a zero duration.
const DefaultMaxBody = 5 << 20
DefaultMaxBody caps how many body bytes are read (5 MiB).
const DefaultTimeout = 20 * time.Second
DefaultTimeout is the per-attempt deadline when Options.Timeout is zero.
Variables ¶
var ErrBlocked = errors.New("unblock: every attempt was blocked")
ErrBlocked is returned when every rung was blocked by a WAF.
var ErrNoBody = errors.New("unblock: empty body")
ErrNoBody is returned when a response carries no usable payload.
Functions ¶
func DefaultHeaders ¶ added in v0.4.0
DefaultHeaders returns a browser-consistent header set for a TLS profile.
tls-client fingerprints the handshake and the HTTP/2 frames but leaves the headers alone, so without this the wire says "Go-http-client/2.0" while the fingerprint claims Chrome — a mismatch any WAF will flag. The browser name and version are read from the profile (GetClientHelloId), never guessed from the profile string.
accept-encoding is deliberately absent: the transport adds its own and decompresses the body. Setting it here leaves the response compressed, which silently shrinks the HTML and breaks substring checks.
Types ¶
type Attempt ¶ added in v0.3.0
type Attempt struct {
Profile string `json:"profile"`
ForceHTTP1 bool `json:"http1,omitempty"`
ProtocolRacing bool `json:"http3,omitempty"`
}
Attempt is one fetch strategy: a TLS profile plus the protocol controls that matter when a WAF rejects a fingerprint.
func DefaultAttempts ¶ added in v0.3.0
func DefaultAttempts() []Attempt
DefaultAttempts is the measured starting order.
Basis: 17 profiles, one WAF-protected page, single GET each.
- HTTP/2 was rejected for every recent profile.
- Racing HTTP/3 answered 200 for chrome_133 and chrome_150 while HTTP/2 was blocked for both, so the protocol is a stronger lever than the profile.
- HTTP/1.1 carried chrome_133, chrome_144, firefox_123, and brave_146.
- chrome_144 and chrome_150 share a JA3N hash yet behaved differently, so the HTTP/2 settings fingerprint is part of what the WAF sees.
Order: mainstream Chrome over h2, the same profile racing h3, the same profile over h1.1, then another engine for when Chrome-shaped traffic is blocked wholesale. Ties break on how common the browser version is in the wild, not on release date.
type AttemptResult ¶ added in v0.3.0
type AttemptResult struct {
Attempt
HTTPStatus int `json:"http_status,omitempty"`
Proto string `json:"proto,omitempty"`
BlockKind string `json:"block_kind,omitempty"`
Error string `json:"error,omitempty"`
OK bool `json:"ok,omitempty"`
}
AttemptResult records what one rung did.
type Block ¶
type Block struct {
// Kind is the category: CF_CHALLENGE, CF_BLOCK_1009, CF_BLOCK_1010,
// CF_BLOCK_1015, CF_BLOCK_1020, CF_BLOCK_GENERIC, WAF_GENERIC.
Kind string `json:"kind"`
// Code is the Cloudflare error code when present (e.g. "1020").
Code string `json:"code,omitempty"`
// RayID helps when contacting WAF support or the site owner.
RayID string `json:"ray_id,omitempty"`
// Server is the value of the Server header.
Server string `json:"server,omitempty"`
// Hint suggests the next action.
Hint string `json:"hint,omitempty"`
}
Block describes a single blocking event.
func Classify ¶
Classify inspects a response and reports whether it is a WAF/anti-bot block. It returns nil when the response is not a block.
Four different situations hide behind HTTP 403, and each needs a different response — never treat them as one case.
Example ¶
Tell block types apart.
package main
import (
"fmt"
"net/http"
"github.com/nyelonong/unblock"
)
func main() {
h := http.Header{}
h.Set("Server", "cloudflare")
h.Set("Cf-Ray", "8a1b2c3d-JKT")
resp := &unblock.Response{
StatusCode: 403,
Header: h,
Body: []byte("<title>Attention Required!</title><h2>Error 1020</h2>"),
}
b := unblock.Classify(resp)
fmt.Println(b.Kind, b.Code, b.Challenge())
fmt.Println(b.Hint)
}
Output: CF_BLOCK_1020 1020 false firewall rule rejected the ASN/datacenter: needs a residential proxy
type BlockedError ¶ added in v0.3.0
type BlockedError struct {
Block *Block
Attempts []AttemptResult
}
BlockedError carries the last block plus the full trail.
func (*BlockedError) Error ¶ added in v0.3.0
func (e *BlockedError) Error() string
func (*BlockedError) Unwrap ¶ added in v0.3.0
func (e *BlockedError) Unwrap() error
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a fetcher with a browser fingerprint.
Example (ForceHTTP1) ¶
Combine both levers: force HTTP/1.1 when QUIC is unavailable in the network and HTTP/2 is blocked. The TLS fingerprint is unchanged.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"time"
"github.com/nyelonong/unblock"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
c := unblock.New(unblock.Options{Timeout: 5 * time.Second, ForceHTTP1: true})
resp, err := c.Get(context.Background(), srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(resp.StatusCode, resp.HTTP1)
}
Output: 200 true
Example (ProtocolRacing) ¶
Race HTTP/3 against HTTP/2 and read the protocol that actually answered.
Many anti-bot rules are scoped to the HTTP/2 fingerprint. Racing QUIC in parallel with TCP often gets an answer where HTTP/2 alone is blocked, and the same TLS profile is reused either way.
Response.Proto tells you which one won, so a "blocked" result is never ambiguous about the transport that produced it.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"time"
"github.com/nyelonong/unblock"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("<html>hello</html>"))
}))
defer srv.Close()
c := unblock.New(unblock.Options{
Profile: "chrome_133",
Timeout: 5 * time.Second,
ProtocolRacing: true,
})
resp, err := c.Get(context.Background(), srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(resp.StatusCode, resp.Proto)
}
Output: 200 HTTP/1.1
func New ¶
New builds a Client. An unusable configuration does not make New fail: the client falls back to plain net/http so callers never have to handle an error on the construction path.
func (*Client) CloseIdleConnections ¶ added in v0.8.0
func (c *Client) CloseIdleConnections()
CloseIdleConnections closes the keep-alive connections the client holds. Call it when the client is done, so a short-lived client does not keep sockets open until the idle timeout.
func (*Client) Do ¶
Do performs the request and returns the full body.
When the first attempt fails because of an HTTP/2 problem (RST_STREAM, INTERNAL_ERROR), the request is retried once over HTTP/1.1 with the same TLS fingerprint. Half of the time budget is reserved for that second attempt.
func (*Client) Get ¶
Get fetches a URL. Redirects are followed.
Example ¶
Fetch a page with a browser fingerprint.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"time"
"github.com/nyelonong/unblock"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("<html>hello</html>"))
}))
defer srv.Close()
c := unblock.New(unblock.Options{Profile: "chrome_133", Timeout: 5 * time.Second})
resp, err := c.Get(context.Background(), srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(resp.StatusCode, len(resp.Body) > 0, resp.Blocked() == nil)
}
Output: 200 true true
func (*Client) Head ¶
Head sends a HEAD request. Some servers reject HEAD; callers that need a body should fall back to Get themselves.
type Cooldown ¶
type Cooldown struct {
// contains filtered or unexported fields
}
Cooldown records hosts that just blocked us.
Cloudflare blocks are sticky: repeating a request to the same host within a short window tends to escalate the block (challenge becomes block). A cooldown buys distance before that host is attempted again.
Example ¶
Use a per-host cooldown.
package main
import (
"fmt"
"time"
"github.com/nyelonong/unblock"
)
func main() {
cd := unblock.NewCooldown(time.Hour)
host := unblock.HostOf("https://example.com/page")
fmt.Println(cd.Allow(host))
cd.Mark(host)
fmt.Println(cd.Allow(host))
}
Output: true false
func NewCooldown ¶
NewCooldown creates a Cooldown with the given window. Zero means DefaultCooldownWindow.
type CooldownError ¶ added in v0.3.0
CooldownError is returned when the host is still parked.
func (*CooldownError) Error ¶ added in v0.3.0
func (e *CooldownError) Error() string
type Fetcher ¶ added in v0.3.0
type Fetcher interface {
Get(ctx context.Context, url string) (*Response, error)
Head(ctx context.Context, url string) (*Response, error)
}
Fetcher is the surface the ladder needs. *Client satisfies it.
type Ladder ¶ added in v0.3.0
type Ladder struct {
// contains filtered or unexported fields
}
Ladder walks attempts in order and stops at the first answer that is not a WAF block. A block means "try the next rung"; any answer from the origin — 200, 404, even 500 — means the site responded, so the ladder stops.
func NewLadder ¶ added in v0.3.0
NewLadder builds a Ladder on top of base options. Attempts that carry an empty Profile inherit nothing from base: a ladder rung is a complete strategy, so set Profile explicitly, or start from DefaultAttempts.
func (*Ladder) CloseIdleConnections ¶ added in v0.8.0
func (l *Ladder) CloseIdleConnections()
CloseIdleConnections closes the keep-alive connections of every rung's client. Call it when a short-lived ladder is done.
func (*Ladder) Get ¶ added in v0.3.0
Get fetches a URL, walking the ladder.
Example ¶
One call walks the whole ladder and stops at the first rung that gets an answer: HTTP/2, then HTTP/3 racing, then HTTP/1.1, then another engine.
A block means "try the next rung". Any answer from the origin — 200, 404, even 500 — ends the walk, because changing fingerprints cannot fix a page that does not exist.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nyelonong/unblock"
)
func main() {
l := unblock.NewLadder(unblock.Options{
Timeout: 20 * time.Second,
Headers: map[string]string{"accept-language": "en-US,en;q=0.9"},
}).WithMaxAttempts(4)
resp, err := l.Get(context.Background(), "https://example.com/")
if err != nil {
var blocked *unblock.BlockedError
if errors.As(err, &blocked) {
fmt.Println("every rung blocked, last kind:", blocked.Block.Kind)
return
}
fmt.Println("request failed:", err)
return
}
fmt.Println(resp.StatusCode, resp.Proto)
for _, a := range l.Last() {
fmt.Printf(" %-32s %-9s %d\n", a.String(), a.Proto, a.HTTPStatus)
}
}
Output:
func (*Ladder) Last ¶ added in v0.3.0
func (l *Ladder) Last() []AttemptResult
Last returns the trail of the most recent walk.
func (*Ladder) Post ¶ added in v0.9.0
Post sends a request with a body, walking the ladder, with the same cooldown and trail as Get.
The next rung resends the same body after a WAF block. A block is answered by the WAF before the origin sees the request, but use Post only for requests that are safe to repeat, such as read-only JSON APIs: the ladder cannot tell whether a request that failed in transit reached the origin, and Client already resends over HTTP/1.1 after an HTTP/2 failure.
func (*Ladder) Winner ¶ added in v0.3.0
Winner returns the fetcher that produced the last successful response, so follow-up requests reuse the same fingerprint. For a follow-up POST, assert it to Poster; a *Client winner satisfies it.
func (*Ladder) WithCooldown ¶ added in v0.3.0
WithCooldown parks a host after a block, so repeated checks do not escalate it.
Example ¶
Park a host after a block so repeated checks do not escalate it.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nyelonong/unblock"
)
func main() {
cd := unblock.NewCooldown(6 * time.Hour)
l := unblock.NewLadder(unblock.Options{Timeout: 20 * time.Second}).
WithCooldown(cd).
WithMaxAttempts(3)
if _, err := l.Get(context.Background(), "https://example.com/"); err != nil {
var cooling *unblock.CooldownError
if errors.As(err, &cooling) {
fmt.Println("still parked for", cooling.Remaining)
}
}
fmt.Println("cooling down:", !cd.Allow(unblock.HostOf("https://example.com/")))
}
Output:
func (*Ladder) WithFetcherFactory ¶ added in v0.3.0
WithFetcherFactory overrides how a rung is served. Intended for tests.
func (*Ladder) WithMaxAttempts ¶ added in v0.3.0
WithMaxAttempts caps how many rungs are tried.
type Options ¶
type Options struct {
// Profile names a TLS preset, e.g. "chrome_133" or "firefox_135".
// Empty means the package default Chrome profile (the newest supported one).
// See profiles.MappedTLSClients for the full list.
Profile string
// ProxyURL is optional: "http://127.0.0.1:8080" or "socks5://10.0.0.2:1080".
ProxyURL string
// Timeout per attempt. Zero means DefaultTimeout.
Timeout time.Duration
// MaxBody caps the number of body bytes read. Zero means DefaultMaxBody.
MaxBody int64
// Headers override the browser-consistent defaults derived from Profile
// (user-agent, sec-ch-ua, accept, sec-fetch-*). Set one only when you know
// the value stays consistent with the profile's fingerprint.
Headers map[string]string
// ForceHTTP1 disables HTTP/2 negotiation entirely. Useful for servers that
// tarpit or consistently reject h2.
ForceHTTP1 bool
// ProtocolRacing races HTTP/3 (QUIC) and HTTP/2 (TCP) and uses whichever
// connects first, similar to Chrome's Happy Eyeballs. The client then reuses
// the winning protocol per host.
//
// This is the strongest lever against anti-bot rules that target the HTTP/2
// fingerprint: measured on a Cloudflare-protected page where every recent
// profile was blocked over HTTP/2, HTTP/3 answered 200 with both
// chrome_133 and chrome_150. Requires UDP egress; if QUIC is blocked the
// client falls back to TCP.
ProtocolRacing bool
// InsecureSkipVerify disables certificate verification. Only for internal
// hosts with self-signed certificates and for tests; it does not affect the
// fingerprint, only trust.
InsecureSkipVerify bool
// Dialer opens every TCP connection, so its Control hook sees each resolved
// address, redirects included. Use it to refuse private networks when the URL
// comes from a user. QUIC does not use it, so setting Dialer disables HTTP/3
// and ProtocolRacing. With ProxyURL set, Control sees only the proxy's
// address: the proxy resolves and connects to the target, so the guard no
// longer applies to it.
Dialer *net.Dialer
}
Options configures a Client.
type Poster ¶ added in v0.9.0
type Poster interface {
Post(ctx context.Context, url, contentType string, body []byte) (*Response, error)
}
Poster is the extra surface Ladder.Post needs from each rung. *Client satisfies it. It is separate from Fetcher so a fetcher that only reads still works everywhere else.
type Response ¶
type Response struct {
StatusCode int
Header http.Header
Body []byte
FinalURL string
// Proto is the negotiated protocol, e.g. "HTTP/2.0" or "HTTP/3.0".
Proto string
// Attempts is 1 for the first try, 2 when the HTTP/1.1 fallback was used.
Attempts int
// HTTP1 is true when the response came from the HTTP/1.1 fallback path.
HTTP1 bool
}
Response is a fully-read response, truncated at MaxBody.