first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

417
core/network/http.go Normal file
View File

@@ -0,0 +1,417 @@
// 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
}
}