package tools import ( "context" "crypto/tls" "fmt" "io" "net" "net/http" "net/url" "time" ) // NewHTTPClient crea el único cliente HTTP de las herramientas (§67). // // No tiene timeout global porque las descargas grandes pueden durar mucho; // cada petición lleva su propio contexto con timeout. El transporte sí // limita las fases que nunca deberían tardar: conexión, TLS y cabeceras. // Reutiliza conexiones (pool) y exige TLS 1.2 o superior. func NewHTTPClient() *http.Client { tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, // respeta HTTPS_PROXY/NO_PROXY DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second, MaxIdleConns: 20, MaxIdleConnsPerHost: 4, IdleConnTimeout: 90 * time.Second, ForceAttemptHTTP2: true, } return &http.Client{ Transport: tr, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("stopped after 10 redirects") } if req.URL.Scheme != "http" && req.URL.Scheme != "https" { return fmt.Errorf("redirect to unsupported scheme %q", req.URL.Scheme) } return nil }, } } // validateURL solo admite http(s) con host. func validateURL(raw string) (*url.URL, error) { u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return nil, fmt.Errorf("invalid URL %q: only http and https are supported", raw) } return u, nil } // httpGet hace un GET con timeout y devuelve como máximo maxBytes del cuerpo. // truncated indica que el cuerpo era más largo. func httpGet(ctx context.Context, env *Env, rawURL string, timeout time.Duration, maxBytes int64, accept string) (body []byte, resp *http.Response, truncated bool, err error) { if _, err := validateURL(rawURL); err != nil { return nil, nil, false, err } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, nil, false, fmt.Errorf("failed to build request for %s: %w", rawURL, err) } req.Header.Set("User-Agent", env.userAgent()) if accept != "" { req.Header.Set("Accept", accept) } resp, err = env.httpClient().Do(req) if err != nil { return nil, nil, false, fmt.Errorf("GET %s: %w", rawURL, err) } defer resp.Body.Close() body, err = io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) if err != nil { return nil, resp, false, fmt.Errorf("failed to read %s: %w", rawURL, err) } if int64(len(body)) > maxBytes { body, truncated = body[:maxBytes], true } return body, resp, truncated, nil } func (e *Env) httpClient() *http.Client { if e.HTTP == nil { e.HTTP = NewHTTPClient() } return e.HTTP } func (e *Env) userAgent() string { if e.UserAgent == "" { return "sysagent/0.1" } return e.UserAgent }