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
211 changes: 160 additions & 51 deletions golang/http2.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,105 +7,214 @@ import (
http2 "github.com/Danny-Dasilva/fhttp/http2"
)

// HTTP2Fingerprint represents an HTTP/2 client fingerprint
// HTTP2Fingerprint represents an HTTP/2 client fingerprint.
// The canonical string format is:
//
// SETTINGS|WINDOW_UPDATE|PRIORITY_FRAMES|PSEUDO_HEADERS
//
// Example Firefox:
//
// 1:65536,4:131072,5:16384|12517377|3:0:0:201,5:0:0:1,7:0:0:1,9:0:7:1,11:0:3:1,13:0:0:241|m,p,a,s
//
// Example Chrome:
//
// 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
type HTTP2Fingerprint struct {
Settings []http2.Setting
StreamDependency uint32
Exclusive bool
PriorityOrder []string
Settings []http2.Setting
ConnectionFlow int
PriorityFrames []http2.PriorityFrame
HeaderPriority *http2.PriorityParam
HeaderTableSize uint32
InitialWindowSize uint32
MaxHeaderListSize uint32
PriorityOrder []string
}

// NewHTTP2Fingerprint creates a new HTTP2Fingerprint from string format
// Format: settings|streamDependency|exclusive|priorityOrder
// Example: "1:65536,2:0,4:6291456,6:262144|15663105|0|m,a,s,p"
// NewHTTP2Fingerprint creates a new HTTP2Fingerprint from a canonical
// HTTP/2 fingerprint string.
//
// The four pipe-separated parts are:
// 1. SETTINGS: a list of "id:value" pairs separated by ',' or ';'.
// 2. WINDOW_UPDATE: the connection-level flow-control increment.
// 3. PRIORITY_FRAMES: either "0" (none) or a comma-separated list of
// "stream:exclusive:dep:weight" priority frames.
// 4. PSEUDO_HEADERS: the order of pseudo-headers, e.g. "m,p,a,s".
func NewHTTP2Fingerprint(fingerprint string) (*HTTP2Fingerprint, error) {
parts := strings.Split(fingerprint, "|")
if len(parts) != 4 {
return nil, fmt.Errorf("invalid HTTP/2 fingerprint format: expected 4 parts, got %d", len(parts))
}

// Parse settings
// Parse SETTINGS.
settingsStr := parts[0]

// Determine the separator used in the settings string
// Determine the separator used in the settings string.
var settingsParts []string
if strings.Contains(settingsStr, ";") && !strings.Contains(settingsStr, ",") {
// If settings use semicolons exclusively, split by semicolon
settingsParts = strings.Split(settingsStr, ";")
} else {
// Default to comma separator
settingsParts = strings.Split(settingsStr, ",")
}

settings := make([]http2.Setting, 0, len(settingsParts))
var headerTableSize, initialWindowSize, maxHeaderListSize uint32

for _, setting := range settingsParts {
var id, val uint32
if strings.Contains(setting, ":") {
// Handle standard format (ID:VALUE)
_, err := fmt.Sscanf(setting, "%d:%d", &id, &val)
if err != nil {
return nil, fmt.Errorf("invalid setting format: %s", setting)
}
} else {
if !strings.Contains(setting, ":") {
return nil, fmt.Errorf("invalid setting format: %s - expected ID:VALUE", setting)
}
var id, val uint32
if _, err := fmt.Sscanf(setting, "%d:%d", &id, &val); err != nil {
return nil, fmt.Errorf("invalid setting format: %s", setting)
}
settings = append(settings, http2.Setting{ID: http2.SettingID(id), Val: val})

switch http2.SettingID(id) {
case http2.SettingHeaderTableSize:
headerTableSize = val
case http2.SettingInitialWindowSize:
initialWindowSize = val
case http2.SettingMaxHeaderListSize:
maxHeaderListSize = val
}
}

// Parse stream dependency
var streamDependency uint32
_, err := fmt.Sscanf(parts[1], "%d", &streamDependency)
if err != nil {
return nil, fmt.Errorf("invalid stream dependency: %s", parts[1])
// Parse WINDOW_UPDATE value (connection-level flow-control increment).
var connectionFlow uint32
if _, err := fmt.Sscanf(parts[1], "%d", &connectionFlow); err != nil {
return nil, fmt.Errorf("invalid WINDOW_UPDATE value: %s", parts[1])
}

// Parse exclusive flag
var exclusiveFlag int
_, err = fmt.Sscanf(parts[2], "%d", &exclusiveFlag)
if err != nil {
return nil, fmt.Errorf("invalid exclusive flag: %s", parts[2])
// Parse PRIORITY frames.
var priorityFrames []http2.PriorityFrame
priorityStr := parts[2]
if priorityStr != "" && priorityStr != "0" {
priorityParts := strings.Split(priorityStr, ",")
priorityFrames = make([]http2.PriorityFrame, 0, len(priorityParts))
for _, p := range priorityParts {
if p == "" {
continue
}
var streamID, exclusive, dep, weight uint32
n, err := fmt.Sscanf(p, "%d:%d:%d:%d", &streamID, &exclusive, &dep, &weight)
if err != nil || n != 4 {
return nil, fmt.Errorf("invalid priority frame format: %s", p)
}
// The fingerprint string uses wire display weights (1-256), but
// fhttp stores the raw 8-bit value (0-255).
if weight > 0 {
weight--
}
priorityFrames = append(priorityFrames, http2.PriorityFrame{
FrameHeader: http2.FrameHeader{StreamID: streamID},
PriorityParam: http2.PriorityParam{
StreamDep: dep,
Exclusive: exclusive != 0,
Weight: uint8(weight),
},
})
}
}
exclusive := exclusiveFlag != 0

// Parse priority order
// Parse pseudo-header order.
priorityOrder := strings.Split(parts[3], ",")

return &HTTP2Fingerprint{
Settings: settings,
StreamDependency: streamDependency,
Exclusive: exclusive,
PriorityOrder: priorityOrder,
Settings: settings,
ConnectionFlow: int(connectionFlow),
PriorityFrames: priorityFrames,
HeaderPriority: nil,
HeaderTableSize: headerTableSize,
InitialWindowSize: initialWindowSize,
MaxHeaderListSize: maxHeaderListSize,
PriorityOrder: priorityOrder,
}, nil
}

// String returns the string representation of the HTTP/2 fingerprint
// String returns the canonical string representation of the HTTP/2 fingerprint.
func (f *HTTP2Fingerprint) String() string {
// Format settings
settingStrs := make([]string, len(f.Settings))
for i, setting := range f.Settings {
settingStrs[i] = fmt.Sprintf("%d:%d", setting.ID, setting.Val)
}
settingsStr := strings.Join(settingStrs, ",")

// Format exclusive flag
exclusiveFlag := 0
if f.Exclusive {
exclusiveFlag = 1
priorityStr := "0"
if len(f.PriorityFrames) > 0 {
parts := make([]string, 0, len(f.PriorityFrames))
for _, pf := range f.PriorityFrames {
exclusive := 0
if pf.Exclusive {
exclusive = 1
}
// Convert the raw 0-255 weight back to the wire display value (1-256).
weight := uint32(pf.Weight) + 1
parts = append(parts, fmt.Sprintf("%d:%d:%d:%d", pf.StreamID, exclusive, pf.StreamDep, weight))
}
priorityStr = strings.Join(parts, ",")
}

// Format priority order
priorityStr := strings.Join(f.PriorityOrder, ",")
pseudoStr := strings.Join(f.PriorityOrder, ",")

return fmt.Sprintf("%s|%d|%s|%s", settingsStr, f.ConnectionFlow, priorityStr, pseudoStr)
}

return fmt.Sprintf("%s|%d|%d|%s", settingsStr, f.StreamDependency, exclusiveFlag, priorityStr)
// Navigator guesses the underlying browser family from the fingerprint.
// This is used to drive fhttp defaults that are still required when a
// fingerprint omits explicit priority frames.
func (f *HTTP2Fingerprint) Navigator() string {
if len(f.PriorityFrames) > 0 {
return firefox
}
for _, s := range f.Settings {
if s.ID == http2.SettingMaxConcurrentStreams || s.ID == http2.SettingMaxHeaderListSize {
return chrome
}
}
return chrome
}

// Apply configures the HTTP/2 connection with the specified fingerprint
// PseudoHeaderOrder maps the fingerprint's pseudo-header order letters to
// actual HTTP/2 pseudo-header names, e.g. "m,p,a,s" -> [":method", ":path", ":authority", ":scheme"].
func (f *HTTP2Fingerprint) PseudoHeaderOrder() []string {
if len(f.PriorityOrder) == 0 {
return nil
}
order := make([]string, 0, len(f.PriorityOrder))
for _, c := range f.PriorityOrder {
switch c {
case "m":
order = append(order, ":method")
case "a":
order = append(order, ":authority")
case "s":
order = append(order, ":scheme")
case "p":
order = append(order, ":path")
}
}
return order
}

// Apply configures the HTTP/2 transport with the specified fingerprint.
func (f *HTTP2Fingerprint) Apply(conn *http2.Transport) {
// Set HTTP/2 settings
conn.Settings = f.Settings
conn.HTTP2Settings = &http2.HTTP2Settings{
Settings: f.Settings,
ConnectionFlow: f.ConnectionFlow,
PriorityFrames: f.PriorityFrames,
HeaderPriority: f.HeaderPriority,
}

// Set priority and weight parameters
// Note: Currently dummy implementation as utls/http2 doesn't expose these directly
// In a real implementation, this would configure the priority tree
// These are used by fhttp for local flow control and HPACK state. They
// may be overwritten by Transport.AutoUpdate depending on the Navigator,
// but we set them here so they match the advertised SETTINGS when possible.
if f.HeaderTableSize != 0 {
conn.HeaderTableSize = f.HeaderTableSize
}
if f.InitialWindowSize != 0 {
conn.InitialWindowSize = f.InitialWindowSize
}
if f.MaxHeaderListSize != 0 {
conn.MaxHeaderListSize = f.MaxHeaderListSize
}
}
61 changes: 48 additions & 13 deletions golang/roundtripper.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ type roundTripper struct {
cachedTransports map[string]http.RoundTripper

dialer proxy.ContextDialer

// Parsed HTTP/2 fingerprint, populated lazily because newRoundTripper
// cannot return an error.
h2FingerprintOnce sync.Once
parsedH2Fingerprint *HTTP2Fingerprint
h2FingerprintErr error
}

func (rt *roundTripper) getH2Fingerprint() (*HTTP2Fingerprint, error) {
rt.h2FingerprintOnce.Do(func() {
if rt.HTTP2Fingerprint != "" {
rt.parsedH2Fingerprint, rt.h2FingerprintErr = NewHTTP2Fingerprint(rt.HTTP2Fingerprint)
}
})
return rt.parsedH2Fingerprint, rt.h2FingerprintErr
}

func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
Expand Down Expand Up @@ -97,8 +112,21 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header = ConvertHttpHeader(MarshalHeader(req.Header, rt.HeaderOrder))

// Note: rt.HeaderOrder contains regular headers like "cache-control", "accept", etc.
// Do NOT overwrite http.PHeaderOrderKey which contains pseudo-headers like ":method", ":path"
// The pseudo-header order is already set correctly in index.go based on UserAgent parsing
// The pseudo-header order is set below from the HTTP/2 fingerprint or UserAgent.
}

// Apply HTTP/2 pseudo-header order from the fingerprint when one is provided,
// overriding the UserAgent-based order set by the caller.
if rt.HTTP2Fingerprint != "" {
h2fp, err := rt.getH2Fingerprint()
if err != nil {
return nil, fmt.Errorf("failed to parse HTTP/2 fingerprint: %w", err)
}
if h2fp != nil {
if pseudoOrder := h2fp.PseudoHeaderOrder(); len(pseudoOrder) > 0 {
req.Header[http.PHeaderOrderKey] = pseudoOrder
}
}
}

// Get address for dialing
Expand Down Expand Up @@ -338,20 +366,23 @@ func (rt *roundTripper) dialTLS(ctx context.Context, network, addr string) (net.
// Use HTTP/2 fingerprint if specified
var http2Transport http2.Transport
if rt.HTTP2Fingerprint != "" {
// Parse and apply HTTP/2 fingerprint
h2Fingerprint, err := NewHTTP2Fingerprint(rt.HTTP2Fingerprint)
h2Fingerprint, err := rt.getH2Fingerprint()
if err != nil {
return nil, fmt.Errorf("failed to parse HTTP/2 fingerprint: %v", err)
}

http2Transport = http2.Transport{
DialTLS: rt.dialTLSHTTP2,
PushHandler: &http2.DefaultPushHandler{},
Navigator: parsedUserAgent.UserAgent,
}

// Apply HTTP/2 fingerprint settings
h2Fingerprint.Apply(&http2Transport)
// Apply HTTP/2 fingerprint settings and use the fingerprint to
// guess the remaining fhttp defaults (e.g. stream ID when no
// explicit priority frames are provided).
if h2Fingerprint != nil {
h2Fingerprint.Apply(&http2Transport)
http2Transport.Navigator = h2Fingerprint.Navigator()
}
} else {
http2Transport = http2.Transport{
DialTLS: rt.dialTLSHTTP2,
Expand Down Expand Up @@ -446,18 +477,20 @@ func (rt *roundTripper) retryWithTLS13CompatibleCurves(ctx context.Context, netw

var http2Transport http2.Transport
if rt.HTTP2Fingerprint != "" {
h2Fingerprint, err := NewHTTP2Fingerprint(rt.HTTP2Fingerprint)
h2Fingerprint, err := rt.getH2Fingerprint()
if err != nil {
return nil, fmt.Errorf("failed to parse HTTP/2 fingerprint for TLS 1.3 retry: %v", err)
}

http2Transport = http2.Transport{
DialTLS: rt.dialTLSHTTP2,
PushHandler: &http2.DefaultPushHandler{},
Navigator: parsedUserAgent.UserAgent,
}

h2Fingerprint.Apply(&http2Transport)
if h2Fingerprint != nil {
h2Fingerprint.Apply(&http2Transport)
http2Transport.Navigator = h2Fingerprint.Navigator()
}
} else {
http2Transport = http2.Transport{
DialTLS: rt.dialTLSHTTP2,
Expand Down Expand Up @@ -536,18 +569,20 @@ func (rt *roundTripper) retryWithOriginalTLS12JA3(ctx context.Context, network,

var http2Transport http2.Transport
if rt.HTTP2Fingerprint != "" {
h2Fingerprint, err := NewHTTP2Fingerprint(rt.HTTP2Fingerprint)
h2Fingerprint, err := rt.getH2Fingerprint()
if err != nil {
return nil, fmt.Errorf("failed to parse HTTP/2 fingerprint for TLS 1.2 fallback: %v", err)
}

http2Transport = http2.Transport{
DialTLS: rt.dialTLSHTTP2,
PushHandler: &http2.DefaultPushHandler{},
Navigator: parsedUserAgent.UserAgent,
}

h2Fingerprint.Apply(&http2Transport)
if h2Fingerprint != nil {
h2Fingerprint.Apply(&http2Transport)
http2Transport.Navigator = h2Fingerprint.Navigator()
}
} else {
http2Transport = http2.Transport{
DialTLS: rt.dialTLSHTTP2,
Expand Down