Skip to content
Draft
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
9 changes: 4 additions & 5 deletions components/backend/gitlab/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strconv"
"time"

"ambient-code-backend/httputil"
"ambient-code-backend/types"
"github.com/google/uuid"
)
Expand All @@ -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,
}
}

Expand Down
10 changes: 6 additions & 4 deletions components/backend/handlers/integration_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"net/http"
"time"

"ambient-code-backend/httputil"

"github.com/gin-gonic/gin"
)

Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
94 changes: 94 additions & 0 deletions components/backend/httputil/transport.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading