package proxy import ( "bytes" "context" "fmt" "io" "net/http" "strings" "time" "optoant/internal/logger" ) // ForwardResult holds the result of a forwarded request. type ForwardResult struct { StatusCode int Body []byte Headers http.Header } // Forward proxies an HTTP request to targetURL, preserving method, headers, // and body. Timeout is applied via context. Sensitive headers are forwarded // but never logged. func Forward(ctx context.Context, method, targetURL string, headers http.Header, body io.Reader, timeoutSec int) (*ForwardResult, error) { if timeoutSec <= 0 { timeoutSec = 30 } ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, method, targetURL, body) if err != nil { return nil, fmt.Errorf("proxy: build request: %w", err) } // Copy allowed headers — never modify; just forward as-is. for key, vals := range headers { lk := strings.ToLower(key) // Skip hop-by-hop headers that would break the proxied connection. switch lk { case "connection", "te", "trailers", "transfer-encoding", "upgrade": continue } for _, v := range vals { req.Header.Add(key, v) } } // Read body for logging (then re-wrap for actual request) bodyBytes, _ := io.ReadAll(body) if len(bodyBytes) > 0 { req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) req.ContentLength = int64(len(bodyBytes)) } logHeader := MaskSensitiveHeaders(req.Header) logger.Debug("[PROXY] --> %s %s | Headers: %v | Body: %s", method, targetURL, logHeader, truncateForLog(string(bodyBytes), 2000)) client := &http.Client{} resp, err := client.Do(req) if err != nil { logger.Warn("[PROXY] <-- ERROR from %s: %v", targetURL, err) return nil, fmt.Errorf("proxy: upstream error: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("proxy: read response body: %w", err) } logger.Debug("[PROXY] <-- RESPONSE %d from %s (%d bytes) | Body: %s", resp.StatusCode, targetURL, len(respBody), truncateForLog(string(respBody), 2000)) return &ForwardResult{ StatusCode: resp.StatusCode, Body: respBody, Headers: resp.Header, }, nil } // MaskSensitiveHeaders returns a copy of headers with Authorization values masked. // Use this copy only for logging purposes. func MaskSensitiveHeaders(h http.Header) http.Header { masked := h.Clone() if masked.Get("Authorization") != "" { masked.Set("Authorization", "Bearer [REDACTED]") } if masked.Get("X-Api-Key") != "" { masked.Set("X-Api-Key", "[REDACTED]") } return masked } func truncateForLog(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "...[TRUNCATED]" }