99 lines
3.0 KiB
Go
99 lines
3.0 KiB
Go
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
|
|
}
|