418 lines
14 KiB
Go
418 lines
14 KiB
Go
// Package network provides centralized HTTP client management with proxy support.
|
|
// It allows runtime proxy configuration updates that propagate to all HTTP clients.
|
|
package network
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/maximhq/bifrost/core/schemas"
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/valyala/fasthttp/fasthttpproxy"
|
|
)
|
|
|
|
// ClientPurpose defines the intended use of an HTTP client for proxy filtering
|
|
type ClientPurpose string
|
|
|
|
const (
|
|
// ClientPurposeSCIM is used for SCIM/OAuth provider requests
|
|
ClientPurposeSCIM ClientPurpose = "scim"
|
|
// ClientPurposeInference is used for LLM inference requests
|
|
ClientPurposeInference ClientPurpose = "inference"
|
|
// ClientPurposeAPI is used for general API requests (guardrails, etc.)
|
|
ClientPurposeAPI ClientPurpose = "api"
|
|
)
|
|
|
|
// DefaultClientConfig holds default timeout values for HTTP clients
|
|
var DefaultClientConfig = struct {
|
|
ReadTimeout time.Duration
|
|
WriteTimeout time.Duration
|
|
MaxIdleConnDuration time.Duration
|
|
MaxConnDuration time.Duration
|
|
MaxConnsPerHost int
|
|
}{
|
|
ReadTimeout: 60 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
MaxIdleConnDuration: 30 * time.Second,
|
|
MaxConnDuration: 300 * time.Second,
|
|
MaxConnsPerHost: 200,
|
|
}
|
|
|
|
// GlobalProxyType represents the type of global proxy
|
|
type GlobalProxyType string
|
|
|
|
const (
|
|
GlobalProxyTypeHTTP GlobalProxyType = "http"
|
|
GlobalProxyTypeSOCKS5 GlobalProxyType = "socks5"
|
|
GlobalProxyTypeTCP GlobalProxyType = "tcp"
|
|
)
|
|
|
|
// GlobalProxyConfig represents the global proxy configuration
|
|
type GlobalProxyConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Type GlobalProxyType `json:"type"` // "http", "socks5", "tcp"
|
|
URL string `json:"url"` // Proxy URL (e.g., http://proxy.example.com:8080)
|
|
Username string `json:"username,omitempty"` // Optional authentication username
|
|
Password string `json:"password,omitempty"` // Optional authentication password
|
|
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated list of hosts to bypass proxy
|
|
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds
|
|
SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` // Skip TLS certificate verification
|
|
// Entity enablement flags
|
|
EnableForSCIM bool `json:"enable_for_scim"` // Enable proxy for SCIM requests (enterprise only)
|
|
EnableForInference bool `json:"enable_for_inference"` // Enable proxy for inference requests
|
|
EnableForAPI bool `json:"enable_for_api"` // Enable proxy for API requests
|
|
}
|
|
|
|
// HTTPClientFactory manages HTTP clients with centralized proxy configuration.
|
|
// It supports both fasthttp and standard net/http clients with purpose-based
|
|
// proxy enablement (SCIM, Inference, API).
|
|
type HTTPClientFactory struct {
|
|
mu sync.RWMutex
|
|
proxyConfig *GlobalProxyConfig
|
|
|
|
// Cached clients per purpose - lazily initialized
|
|
fasthttpClients map[ClientPurpose]*fasthttp.Client
|
|
httpClients map[ClientPurpose]*http.Client
|
|
|
|
logger schemas.Logger
|
|
}
|
|
|
|
// NewHTTPClientFactory creates a new HTTP client factory with the given proxy configuration.
|
|
// Pass nil for proxyConfig if proxy is not yet configured.
|
|
func NewHTTPClientFactory(proxyConfig *GlobalProxyConfig, logger schemas.Logger) *HTTPClientFactory {
|
|
return &HTTPClientFactory{
|
|
proxyConfig: proxyConfig,
|
|
fasthttpClients: make(map[ClientPurpose]*fasthttp.Client, 3),
|
|
httpClients: make(map[ClientPurpose]*http.Client, 3),
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// UpdateProxyConfig updates the proxy configuration and recreates all cached clients.
|
|
// This is thread-safe and can be called at runtime.
|
|
func (f *HTTPClientFactory) UpdateProxyConfig(config *GlobalProxyConfig) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
f.proxyConfig = config
|
|
|
|
// Clear cached clients - they will be recreated on next request
|
|
f.fasthttpClients = make(map[ClientPurpose]*fasthttp.Client, 3)
|
|
f.httpClients = make(map[ClientPurpose]*http.Client, 3)
|
|
}
|
|
|
|
// GetProxyConfig returns the current proxy configuration (thread-safe read)
|
|
func (f *HTTPClientFactory) GetProxyConfig() *GlobalProxyConfig {
|
|
f.mu.RLock()
|
|
defer f.mu.RUnlock()
|
|
return f.proxyConfig
|
|
}
|
|
|
|
// isProxyEnabledForPurpose checks if proxy should be used for the given purpose
|
|
func (f *HTTPClientFactory) isProxyEnabledForPurpose(purpose ClientPurpose) bool {
|
|
if f.proxyConfig == nil || !f.proxyConfig.Enabled {
|
|
return false
|
|
}
|
|
|
|
switch purpose {
|
|
case ClientPurposeSCIM:
|
|
return f.proxyConfig.EnableForSCIM
|
|
case ClientPurposeInference:
|
|
return f.proxyConfig.EnableForInference
|
|
case ClientPurposeAPI:
|
|
return f.proxyConfig.EnableForAPI
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// shouldBypassProxy checks if a host matches a noProxy pattern
|
|
// Supported patterns:
|
|
// - "*" matches all hosts
|
|
// - ".example.com" matches example.com and all subdomains
|
|
// - "*.example.com" matches subdomains of example.com only
|
|
// - exact host match
|
|
func shouldBypassProxy(host, pattern string) bool {
|
|
host = strings.ToLower(strings.TrimSpace(host))
|
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
|
|
|
if pattern == "*" {
|
|
return true
|
|
}
|
|
if pattern == host {
|
|
return true
|
|
}
|
|
// .example.com matches example.com and *.example.com
|
|
if strings.HasPrefix(pattern, ".") {
|
|
suffix := pattern[1:] // remove leading dot
|
|
return host == suffix || strings.HasSuffix(host, pattern)
|
|
}
|
|
// *.example.com matches subdomains only
|
|
if strings.HasPrefix(pattern, "*.") {
|
|
suffix := pattern[1:] // keep the dot, e.g., ".example.com"
|
|
return strings.HasSuffix(host, suffix)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetFasthttpClient returns a fasthttp client configured for the given purpose.
|
|
// If proxy is enabled for this purpose, the client will be configured with proxy settings.
|
|
// Clients are cached and reused until proxy config changes.
|
|
func (f *HTTPClientFactory) GetFasthttpClient(purpose ClientPurpose) *fasthttp.Client {
|
|
f.mu.RLock()
|
|
if client, ok := f.fasthttpClients[purpose]; ok {
|
|
f.mu.RUnlock()
|
|
return client
|
|
}
|
|
f.mu.RUnlock()
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
// Double-check after acquiring write lock
|
|
if client, ok := f.fasthttpClients[purpose]; ok {
|
|
return client
|
|
}
|
|
|
|
client := f.createFasthttpClient(purpose)
|
|
f.fasthttpClients[purpose] = client
|
|
return client
|
|
}
|
|
|
|
// GetHTTPClient returns a standard net/http client configured for the given purpose.
|
|
// If proxy is enabled for this purpose, the client will be configured with proxy settings.
|
|
// Clients are cached and reused until proxy config changes.
|
|
func (f *HTTPClientFactory) GetHTTPClient(purpose ClientPurpose) *http.Client {
|
|
f.mu.RLock()
|
|
if client, ok := f.httpClients[purpose]; ok {
|
|
f.mu.RUnlock()
|
|
return client
|
|
}
|
|
f.mu.RUnlock()
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
// Double-check after acquiring write lock
|
|
if client, ok := f.httpClients[purpose]; ok {
|
|
return client
|
|
}
|
|
|
|
client := f.createHTTPClient(purpose)
|
|
f.httpClients[purpose] = client
|
|
return client
|
|
}
|
|
|
|
// createFasthttpClient creates a new fasthttp client with appropriate proxy settings
|
|
func (f *HTTPClientFactory) createFasthttpClient(purpose ClientPurpose) *fasthttp.Client {
|
|
client := &fasthttp.Client{
|
|
ReadTimeout: DefaultClientConfig.ReadTimeout,
|
|
WriteTimeout: DefaultClientConfig.WriteTimeout,
|
|
MaxIdleConnDuration: DefaultClientConfig.MaxIdleConnDuration,
|
|
MaxConnDuration: DefaultClientConfig.MaxConnDuration,
|
|
MaxConnsPerHost: DefaultClientConfig.MaxConnsPerHost,
|
|
MaxConnWaitTimeout: DefaultClientConfig.ReadTimeout,
|
|
ConnPoolStrategy: fasthttp.FIFO,
|
|
RetryIfErr: StaleConnectionRetryIfErr,
|
|
}
|
|
|
|
// Configure proxy if enabled for this purpose
|
|
if f.isProxyEnabledForPurpose(purpose) {
|
|
f.configureFasthttpProxy(client)
|
|
}
|
|
|
|
// Configure TLS if skip verification is set
|
|
if f.proxyConfig != nil {
|
|
if f.proxyConfig.SkipTLSVerify {
|
|
f.logger.Warn("skipping TLS verification for fasthttp client because skip TLS verify is set to true. It's not recommended to use this in production.")
|
|
}
|
|
client.TLSConfig = &tls.Config{
|
|
InsecureSkipVerify: f.proxyConfig.SkipTLSVerify,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
}
|
|
|
|
return client
|
|
}
|
|
|
|
// StaleConnectionRetryIfErr is a RetryIfErr callback that retries requests when the failure
|
|
// is due to a stale/dead connection being reused from the pool. This addresses intermittent
|
|
// "cannot find whitespace in the first line of response" errors caused by connection reuse
|
|
// with leftover chunked transfer encoding data (see: https://github.com/valyala/fasthttp/issues/1743).
|
|
//
|
|
// By default fasthttp only retries idempotent requests (GET/HEAD/PUT). LLM inference requests
|
|
// use POST, so without this they fail immediately on stale connections. Retrying is safe here
|
|
// because the error occurs during response header parsing — before the server processes the
|
|
// new request, or on a connection the server has already closed.
|
|
func StaleConnectionRetryIfErr(_ *fasthttp.Request, attempts int, err error) (resetTimeout bool, retry bool) {
|
|
if attempts > 1 {
|
|
return false, false
|
|
}
|
|
if err == nil {
|
|
return false, false
|
|
}
|
|
errStr := err.Error()
|
|
// io.EOF — server closed the connection (fasthttp converts this to
|
|
// ErrConnectionClosed AFTER the retry loop, so RetryIfErr sees raw EOF)
|
|
// "cannot find whitespace in the first line of response" — stale chunked data in buffer
|
|
// "connection reset by peer" — server RST'd the idle connection (read-side)
|
|
// "broken pipe" — server closed the idle connection (write-side EPIPE)
|
|
if err == io.EOF ||
|
|
strings.Contains(errStr, "cannot find whitespace") ||
|
|
strings.Contains(errStr, "connection reset by peer") ||
|
|
strings.Contains(errStr, "broken pipe") {
|
|
return true, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// buildProxyURLWithAuth adds authentication to a proxy URL if credentials are provided
|
|
func (f *HTTPClientFactory) buildProxyURLWithAuth() string {
|
|
proxyURL := f.proxyConfig.URL
|
|
if f.proxyConfig.Username != "" && f.proxyConfig.Password != "" {
|
|
parsedURL, err := url.Parse(f.proxyConfig.URL)
|
|
if err == nil {
|
|
parsedURL.User = url.UserPassword(f.proxyConfig.Username, f.proxyConfig.Password)
|
|
proxyURL = parsedURL.String()
|
|
}
|
|
}
|
|
return proxyURL
|
|
}
|
|
|
|
// configureFasthttpProxy configures proxy for a fasthttp client
|
|
func (f *HTTPClientFactory) configureFasthttpProxy(client *fasthttp.Client) {
|
|
if f.proxyConfig == nil || f.proxyConfig.URL == "" {
|
|
return
|
|
}
|
|
|
|
proxyURL := f.buildProxyURLWithAuth()
|
|
var dialFunc fasthttp.DialFunc
|
|
|
|
switch f.proxyConfig.Type {
|
|
case GlobalProxyTypeHTTP:
|
|
dialFunc = fasthttpproxy.FasthttpHTTPDialer(proxyURL)
|
|
case GlobalProxyTypeSOCKS5:
|
|
dialFunc = fasthttpproxy.FasthttpSocksDialer(proxyURL)
|
|
}
|
|
|
|
proxyCfg := f.proxyConfig
|
|
if dialFunc != nil {
|
|
client.Dial = func(addr string) (net.Conn, error) {
|
|
if proxyCfg.NoProxy != "" {
|
|
host := strings.Split(addr, ":")[0]
|
|
if host == "" {
|
|
host = addr
|
|
}
|
|
if shouldBypassProxy(host, proxyCfg.NoProxy) {
|
|
return net.Dial("tcp", addr)
|
|
}
|
|
}
|
|
return dialFunc(addr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// createHTTPClient creates a new standard net/http client with appropriate proxy settings
|
|
func (f *HTTPClientFactory) createHTTPClient(purpose ClientPurpose) *http.Client {
|
|
transport := &http.Transport{
|
|
MaxIdleConns: DefaultClientConfig.MaxConnsPerHost,
|
|
MaxIdleConnsPerHost: DefaultClientConfig.MaxConnsPerHost,
|
|
IdleConnTimeout: DefaultClientConfig.MaxIdleConnDuration,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ResponseHeaderTimeout: DefaultClientConfig.ReadTimeout,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
DisableCompression: false,
|
|
DisableKeepAlives: false,
|
|
// Disable HTTP/2 — these clients are used for auxiliary purposes (proxy/SCIM/API)
|
|
// where HTTP/1.1 is sufficient. Without this, Go's http2 package auto-registers
|
|
// h2 via TLSNextProto in init(), causing unintended HTTP/2 connections.
|
|
TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper),
|
|
}
|
|
|
|
// Configure proxy if enabled for this purpose
|
|
if f.isProxyEnabledForPurpose(purpose) {
|
|
f.configureHTTPProxy(transport)
|
|
}
|
|
|
|
// Configure TLS if skip verification is set
|
|
if f.proxyConfig != nil {
|
|
if f.proxyConfig.SkipTLSVerify {
|
|
f.logger.Warn("skipping TLS verification for fasthttp client because skip TLS verify is set to true. It's not recommended to use this in production.")
|
|
}
|
|
transport.TLSClientConfig = &tls.Config{
|
|
InsecureSkipVerify: f.proxyConfig.SkipTLSVerify,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
}
|
|
|
|
timeout := DefaultClientConfig.ReadTimeout
|
|
if f.proxyConfig != nil && f.proxyConfig.Timeout > 0 {
|
|
timeout = time.Duration(f.proxyConfig.Timeout) * time.Second
|
|
}
|
|
|
|
return &http.Client{
|
|
Transport: transport,
|
|
Timeout: timeout,
|
|
}
|
|
}
|
|
|
|
// configureHTTPProxy configures proxy for a standard net/http transport
|
|
func (f *HTTPClientFactory) configureHTTPProxy(transport *http.Transport) {
|
|
if f.proxyConfig == nil || f.proxyConfig.URL == "" {
|
|
return
|
|
}
|
|
|
|
proxyURL, err := url.Parse(f.proxyConfig.URL)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Add authentication if provided
|
|
if f.proxyConfig.Username != "" && f.proxyConfig.Password != "" {
|
|
proxyURL.User = url.UserPassword(f.proxyConfig.Username, f.proxyConfig.Password)
|
|
|
|
// For HTTPS requests through HTTP proxy, the CONNECT method is used to establish a tunnel.
|
|
// Proxy authentication must be sent via ProxyConnectHeader for the CONNECT request.
|
|
// Without this, the proxy will reject/reset the connection before the TLS handshake.
|
|
basicAuth := "Basic " + base64.StdEncoding.EncodeToString(
|
|
[]byte(f.proxyConfig.Username+":"+f.proxyConfig.Password),
|
|
)
|
|
transport.ProxyConnectHeader = http.Header{
|
|
"Proxy-Authorization": {basicAuth},
|
|
}
|
|
}
|
|
|
|
// Capture noProxy patterns at creation time to avoid data race with UpdateProxyConfig.
|
|
// The closure below is called for each request and would otherwise read f.proxyConfig
|
|
// concurrently with writes from UpdateProxyConfig.
|
|
var noProxyPatterns []string
|
|
if f.proxyConfig.NoProxy != "" {
|
|
noProxyPatterns = strings.Split(f.proxyConfig.NoProxy, ",")
|
|
}
|
|
|
|
proxyCfg := f.proxyConfig
|
|
transport.Proxy = func(req *http.Request) (*url.URL, error) {
|
|
// Use Hostname() to get the host without port for matching.
|
|
// req.URL.Host is "host:port" but no_proxy patterns are host-only.
|
|
if proxyCfg.NoProxy != "" {
|
|
host := req.URL.Hostname()
|
|
if host == "" {
|
|
host = req.URL.Host
|
|
}
|
|
for _, np := range noProxyPatterns {
|
|
if shouldBypassProxy(host, np) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
}
|
|
return proxyURL, nil
|
|
}
|
|
}
|