diff --git a/components/backend/gitlab/client.go b/components/backend/gitlab/client.go index c3a69711b..27440ab3c 100644 --- a/components/backend/gitlab/client.go +++ b/components/backend/gitlab/client.go @@ -12,6 +12,7 @@ import ( "strconv" "time" + "ambient-code-backend/httputil" "ambient-code-backend/types" "github.com/google/uuid" ) @@ -31,11 +32,9 @@ type Client struct { // NewClient creates a new GitLab API client with 15-second timeout func NewClient(baseURL, token string) *Client { return &Client{ - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, - baseURL: baseURL, - token: token, + httpClient: httputil.NewClient(15 * time.Second), + baseURL: baseURL, + token: token, } } diff --git a/components/backend/handlers/integration_validation.go b/components/backend/handlers/integration_validation.go index 7b034e75e..e69ebe921 100644 --- a/components/backend/handlers/integration_validation.go +++ b/components/backend/handlers/integration_validation.go @@ -6,6 +6,8 @@ import ( "net/http" "time" + "ambient-code-backend/httputil" + "github.com/gin-gonic/gin" ) @@ -15,7 +17,7 @@ func ValidateGitHubToken(ctx context.Context, token string) (bool, error) { return false, fmt.Errorf("token is empty") } - client := &http.Client{Timeout: 10 * time.Second} + client := httputil.NewClient(10 * time.Second) req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil) if err != nil { return false, fmt.Errorf("failed to create request") @@ -44,7 +46,7 @@ func ValidateGitLabToken(ctx context.Context, token, instanceURL string) (bool, instanceURL = "https://gitlab.com" } - client := &http.Client{Timeout: 10 * time.Second} + client := httputil.NewClient(10 * time.Second) apiURL := fmt.Sprintf("%s/api/v4/user", instanceURL) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) @@ -72,7 +74,7 @@ func ValidateJiraToken(ctx context.Context, url, email, apiToken string) (bool, return false, fmt.Errorf("missing required credentials") } - client := &http.Client{Timeout: 15 * time.Second} + client := httputil.NewClient(15 * time.Second) // Try API v3 first (Jira Cloud), fallback to v2 (Jira Server/DC) apiURLs := []string{ @@ -123,7 +125,7 @@ func ValidateGoogleToken(ctx context.Context, accessToken string) (bool, error) return false, fmt.Errorf("token is empty") } - client := &http.Client{Timeout: 10 * time.Second} + client := httputil.NewClient(10 * time.Second) req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo", nil) if err != nil { diff --git a/components/backend/httputil/transport.go b/components/backend/httputil/transport.go new file mode 100755 index 000000000..d7b52d3cb --- /dev/null +++ b/components/backend/httputil/transport.go @@ -0,0 +1,94 @@ +// Package httputil provides a shared HTTP transport with custom CA support. +package httputil + +import ( + "crypto/tls" + "crypto/x509" + "log" + "net" + "net/http" + "os" + "sync" + "time" +) + +var ( + sharedTransport *http.Transport + once sync.Once +) + +// Transport returns a shared http.Transport configured with custom CA +// certificates when available. It appends certificates from the file +// specified by the CUSTOM_CA_BUNDLE environment variable to the system +// certificate pool. The transport is created once and reused for all +// callers so that idle connections are shared. +func Transport() *http.Transport { + once.Do(func() { + sharedTransport = buildTransport() + }) + return sharedTransport +} + +// NewClient returns an *http.Client that uses the shared transport with +// custom CA support and the given timeout. +func NewClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: Transport(), + } +} + +func buildTransport() *http.Transport { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if caBundle := os.Getenv("CUSTOM_CA_BUNDLE"); caBundle != "" { + if pool := loadCustomCAs(caBundle); pool != nil { + tlsConfig.RootCAs = pool + } + } + + // Mirror net/http.DefaultTransport settings so we preserve proxy, + // HTTP/2, dial timeouts, and keep-alive behaviour. + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + ForceAttemptHTTP2: true, + TLSClientConfig: tlsConfig, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } +} + +// loadCustomCAs returns a cert pool that combines the system CAs with the +// PEM certificates in bundlePath. It returns nil if the extra certs cannot +// be loaded, so callers can fall back to Go's default behaviour. +func loadCustomCAs(bundlePath string) *x509.CertPool { + pem, err := os.ReadFile(bundlePath) + if err != nil { + log.Printf("WARNING: failed to read CUSTOM_CA_BUNDLE (%s): %v", bundlePath, err) + return nil + } + + pool, err := x509.SystemCertPool() + if err != nil { + log.Printf("WARNING: failed to load system cert pool, creating empty pool: %v", err) + pool = x509.NewCertPool() + } + + if !pool.AppendCertsFromPEM(pem) { + log.Printf("WARNING: CUSTOM_CA_BUNDLE (%s) contained no valid PEM certificates", bundlePath) + return nil + } + + log.Printf("Loaded custom CA certificates from %s", bundlePath) + return pool +}