mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 11:19:08 +02:00
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
|
|
}
|