mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-14 10:49:07 +02:00
ForceAttemptHTTP2 only puts h2 in the ALPN offer — the upstream still picks — so "auto" already meant "whatever the upstream chose". What ALPN cannot express is an upstream that selects h2 and then fails to speak it, which is the case the setting was added for: today that leaves the operator pinning every upstream to 1.1 to work around one broken backend. Auto now completes itself. The first h2-level failure for a host pins that host to an HTTP/1.1-only clone of its transport for 10 minutes and retries the request there when the body can be replayed, so a broken backend costs one failed h2 attempt instead of a configuration change. The pin is per upstream host, so one broken backend does not drop the others, and it expires so a fixed backend returns to h2 on its own. Only h2 framing errors trigger it: a dial, TLS or context error says nothing about the protocol and retrying it over HTTP/1.1 would fix nothing. The explicit values stay absolute — "2" never downgrades. Pinning HTTP/1.1 now also strips h2 from the ALPN offer. Configuring h2 makes net/http append it to the transport's TLSClientConfig, so a clone taken from a transport that already served a request would otherwise advertise a protocol the clone refuses to speak, and the reply would come back as h2 frames parsed as an HTTP/1.1 message.
261 lines
8.6 KiB
Go
261 lines
8.6 KiB
Go
package roundtrip
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// Environment variable names for tuning the backend HTTP transport.
|
|
const (
|
|
EnvMaxIdleConns = "NB_PROXY_MAX_IDLE_CONNS"
|
|
EnvMaxIdleConnsPerHost = "NB_PROXY_MAX_IDLE_CONNS_PER_HOST"
|
|
EnvMaxConnsPerHost = "NB_PROXY_MAX_CONNS_PER_HOST"
|
|
EnvIdleConnTimeout = "NB_PROXY_IDLE_CONN_TIMEOUT"
|
|
EnvTLSHandshakeTimeout = "NB_PROXY_TLS_HANDSHAKE_TIMEOUT"
|
|
EnvExpectContinueTimeout = "NB_PROXY_EXPECT_CONTINUE_TIMEOUT"
|
|
EnvResponseHeaderTimeout = "NB_PROXY_RESPONSE_HEADER_TIMEOUT"
|
|
EnvWriteBufferSize = "NB_PROXY_WRITE_BUFFER_SIZE"
|
|
EnvReadBufferSize = "NB_PROXY_READ_BUFFER_SIZE"
|
|
EnvDisableCompression = "NB_PROXY_DISABLE_COMPRESSION"
|
|
EnvMaxInflight = "NB_PROXY_MAX_INFLIGHT"
|
|
EnvUpstreamHTTPVersion = "NB_PROXY_UPSTREAM_HTTP_VERSION"
|
|
)
|
|
|
|
// upstreamHTTPVersion selects the HTTP version the proxy uses towards an
|
|
// upstream. The explicit values are absolute: they mean the same thing
|
|
// however the transports are dialled and whatever the default becomes,
|
|
// so operator configuration survives a change of default.
|
|
type upstreamHTTPVersion string
|
|
|
|
const (
|
|
// upstreamHTTPAuto leaves the choice to the upstream: h2 is offered
|
|
// alongside http/1.1 in the TLS handshake and the upstream picks.
|
|
// An upstream that picks h2 and then fails to serve it is moved to
|
|
// HTTP/1.1 on its own (see upstreamTransport), which is the part
|
|
// ALPN cannot express. This is the only value whose meaning tracks
|
|
// the proxy's default.
|
|
upstreamHTTPAuto upstreamHTTPVersion = "auto"
|
|
// upstreamHTTP11 never offers h2, so the upstream sees HTTP/1.1.
|
|
upstreamHTTP11 upstreamHTTPVersion = "1.1"
|
|
// upstreamHTTP2 offers h2 in the TLS handshake and keeps it there:
|
|
// an upstream that negotiates h2 and then breaks is never moved to
|
|
// HTTP/1.1. Cleartext upstreams stay on HTTP/1.1 regardless: the
|
|
// proxy speaks no h2c.
|
|
upstreamHTTP2 upstreamHTTPVersion = "2"
|
|
)
|
|
|
|
// transportConfig holds tunable parameters for the per-account HTTP transport.
|
|
type transportConfig struct {
|
|
maxIdleConns int
|
|
maxIdleConnsPerHost int
|
|
maxConnsPerHost int
|
|
idleConnTimeout time.Duration
|
|
tlsHandshakeTimeout time.Duration
|
|
expectContinueTimeout time.Duration
|
|
responseHeaderTimeout time.Duration
|
|
writeBufferSize int
|
|
readBufferSize int
|
|
disableCompression bool
|
|
// maxInflight limits per-backend concurrent requests. 0 means unlimited.
|
|
maxInflight int
|
|
// upstreamHTTPVersion selects the HTTP version used towards HTTPS
|
|
// upstreams. The default negotiates it with each upstream; the
|
|
// explicit values are for backends whose advertised h2 support is
|
|
// unusable and whose failure mode the negotiation cannot see.
|
|
upstreamHTTPVersion upstreamHTTPVersion
|
|
}
|
|
|
|
func defaultTransportConfig() transportConfig {
|
|
return transportConfig{
|
|
maxIdleConns: 100,
|
|
maxIdleConnsPerHost: 100,
|
|
maxConnsPerHost: 0, // unlimited
|
|
idleConnTimeout: 90 * time.Second,
|
|
tlsHandshakeTimeout: 10 * time.Second,
|
|
expectContinueTimeout: 1 * time.Second,
|
|
upstreamHTTPVersion: upstreamHTTPAuto,
|
|
}
|
|
}
|
|
|
|
func loadTransportConfig(logger *log.Logger) transportConfig {
|
|
cfg := defaultTransportConfig()
|
|
|
|
if v, ok := envInt(EnvMaxIdleConns, logger); ok {
|
|
cfg.maxIdleConns = v
|
|
}
|
|
if v, ok := envInt(EnvMaxIdleConnsPerHost, logger); ok {
|
|
cfg.maxIdleConnsPerHost = v
|
|
}
|
|
if v, ok := envInt(EnvMaxConnsPerHost, logger); ok {
|
|
cfg.maxConnsPerHost = v
|
|
}
|
|
if v, ok := envDuration(EnvIdleConnTimeout, logger); ok {
|
|
cfg.idleConnTimeout = v
|
|
}
|
|
if v, ok := envDuration(EnvTLSHandshakeTimeout, logger); ok {
|
|
cfg.tlsHandshakeTimeout = v
|
|
}
|
|
if v, ok := envDuration(EnvExpectContinueTimeout, logger); ok {
|
|
cfg.expectContinueTimeout = v
|
|
}
|
|
if v, ok := envDuration(EnvResponseHeaderTimeout, logger); ok {
|
|
cfg.responseHeaderTimeout = v
|
|
}
|
|
if v, ok := envInt(EnvWriteBufferSize, logger); ok {
|
|
cfg.writeBufferSize = v
|
|
}
|
|
if v, ok := envInt(EnvReadBufferSize, logger); ok {
|
|
cfg.readBufferSize = v
|
|
}
|
|
if v, ok := envBool(EnvDisableCompression, logger); ok {
|
|
cfg.disableCompression = v
|
|
}
|
|
if v, ok := envInt(EnvMaxInflight, logger); ok {
|
|
cfg.maxInflight = v
|
|
}
|
|
if v, ok := envUpstreamHTTPVersion(EnvUpstreamHTTPVersion, logger); ok {
|
|
cfg.upstreamHTTPVersion = v
|
|
}
|
|
|
|
logger.WithFields(log.Fields{
|
|
"max_idle_conns": cfg.maxIdleConns,
|
|
"max_idle_conns_per_host": cfg.maxIdleConnsPerHost,
|
|
"max_conns_per_host": cfg.maxConnsPerHost,
|
|
"idle_conn_timeout": cfg.idleConnTimeout,
|
|
"tls_handshake_timeout": cfg.tlsHandshakeTimeout,
|
|
"expect_continue_timeout": cfg.expectContinueTimeout,
|
|
"response_header_timeout": cfg.responseHeaderTimeout,
|
|
"write_buffer_size": cfg.writeBufferSize,
|
|
"read_buffer_size": cfg.readBufferSize,
|
|
"disable_compression": cfg.disableCompression,
|
|
"max_inflight": cfg.maxInflight,
|
|
"upstream_http_version": cfg.upstreamHTTPVersion,
|
|
}).Debug("backend transport configuration")
|
|
|
|
return cfg
|
|
}
|
|
|
|
// applyUpstreamHTTPVersion configures t's ALPN offer for the requested
|
|
// HTTP version. It is the single place that decides which protocols a
|
|
// transport offers, so changing the proxy's default only touches this
|
|
// function and leaves every explicit operator setting intact. What
|
|
// happens when a negotiated h2 upstream then fails belongs to
|
|
// upstreamTransport, which owns the runtime half of "auto".
|
|
//
|
|
// HTTP/1.1 is pinned by clearing ForceAttemptHTTP2 and installing an
|
|
// empty TLSNextProto, which disables h2 regardless of how the transport
|
|
// is dialled. Relying on net/http's conservative default (h2 off
|
|
// whenever a custom dialer is set) would silently start negotiating h2
|
|
// again the day a transport switches to DialTLSContext.
|
|
func applyUpstreamHTTPVersion(t *http.Transport, version upstreamHTTPVersion) {
|
|
if version == upstreamHTTP11 {
|
|
t.ForceAttemptHTTP2 = false
|
|
t.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
|
|
t.TLSClientConfig = withoutHTTP2ALPN(t.TLSClientConfig)
|
|
return
|
|
}
|
|
t.ForceAttemptHTTP2 = true
|
|
}
|
|
|
|
// withoutHTTP2ALPN drops h2 from the ALPN offer. Configuring h2 makes
|
|
// net/http append h2 to the transport's TLSClientConfig, so a transport
|
|
// cloned from one that already served a request carries that offer with
|
|
// it. Left in place, the upstream would select a protocol this
|
|
// transport then refuses to speak, and the response would come back as
|
|
// h2 frames parsed as an HTTP/1.1 message.
|
|
func withoutHTTP2ALPN(cfg *tls.Config) *tls.Config {
|
|
// A nil config offers no ALPN at all, which is already HTTP/1.1.
|
|
if cfg == nil || len(cfg.NextProtos) == 0 {
|
|
return cfg
|
|
}
|
|
|
|
protos := make([]string, 0, len(cfg.NextProtos))
|
|
for _, proto := range cfg.NextProtos {
|
|
if proto == "h2" {
|
|
continue
|
|
}
|
|
protos = append(protos, proto)
|
|
}
|
|
if len(protos) == len(cfg.NextProtos) {
|
|
return cfg
|
|
}
|
|
|
|
// Clone rather than edit in place: the caller may share this config
|
|
// with the transport it was cloned from.
|
|
stripped := cfg.Clone()
|
|
stripped.NextProtos = protos
|
|
|
|
return stripped
|
|
}
|
|
|
|
// envUpstreamHTTPVersion reads an upstream HTTP version from the
|
|
// environment. An unrecognised value warns and leaves the default in
|
|
// place rather than guessing at the operator's intent.
|
|
func envUpstreamHTTPVersion(key string, logger *log.Logger) (upstreamHTTPVersion, bool) {
|
|
s := strings.TrimSpace(os.Getenv(key))
|
|
if s == "" {
|
|
return "", false
|
|
}
|
|
switch v := upstreamHTTPVersion(strings.ToLower(s)); v {
|
|
case upstreamHTTPAuto, upstreamHTTP11, upstreamHTTP2:
|
|
return v, true
|
|
default:
|
|
logger.Warnf("ignoring unsupported %s=%q, expected one of %q, %q, %q",
|
|
key, s, upstreamHTTPAuto, upstreamHTTP11, upstreamHTTP2)
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func envInt(key string, logger *log.Logger) (int, bool) {
|
|
s := os.Getenv(key)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
logger.Warnf("failed to parse %s=%q as int: %v", key, s, err)
|
|
return 0, false
|
|
}
|
|
if v < 0 {
|
|
logger.Warnf("ignoring negative value for %s=%d", key, v)
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
func envDuration(key string, logger *log.Logger) (time.Duration, bool) {
|
|
s := os.Getenv(key)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
v, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
logger.Warnf("failed to parse %s=%q as duration: %v", key, s, err)
|
|
return 0, false
|
|
}
|
|
if v < 0 {
|
|
logger.Warnf("ignoring negative value for %s=%s", key, v)
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
func envBool(key string, logger *log.Logger) (bool, bool) {
|
|
s := os.Getenv(key)
|
|
if s == "" {
|
|
return false, false
|
|
}
|
|
v, err := strconv.ParseBool(s)
|
|
if err != nil {
|
|
logger.Warnf("failed to parse %s=%q as bool: %v", key, s, err)
|
|
return false, false
|
|
}
|
|
return v, true
|
|
}
|