mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-03 03:59:07 +02:00
Direct-upstream targets are dialled on the proxy host's network stack, outside the embedded client's LAN blocking. A proxy that serves untrusted accounts lets them reach the host's loopback, its LAN or cluster, and the cloud metadata service through such a target. NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE adds a dialer control that refuses addresses that are not globally reachable. It checks each socket's resolved address just before connect, so hostnames and DNS rebinding are covered, and IPv4 embedded in IPv6 addresses is checked as IPv4. Refused dials are served as a 502. The setting defaults to off for private and self-hosted proxies; an unparsable value turns it on.
288 lines
9.8 KiB
Go
288 lines
9.8 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"
|
|
// EnvDirectUpstreamBlockPrivate refuses direct-upstream dials to
|
|
// addresses that are not globally reachable (loopback, private,
|
|
// link-local, CGNAT, ...). Off by default: private and self-hosted
|
|
// proxies use direct_upstream to reach LAN and localhost services.
|
|
// Proxies that serve untrusted accounts must turn it on.
|
|
EnvDirectUpstreamBlockPrivate = "NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE"
|
|
)
|
|
|
|
// 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
|
|
// blockPrivateUpstreams guards the direct branches' dialer with
|
|
// guardUpstreamDial. It has no effect on the embedded branch.
|
|
blockPrivateUpstreams bool
|
|
}
|
|
|
|
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
|
|
}
|
|
cfg.blockPrivateUpstreams = envGuardBool(EnvDirectUpstreamBlockPrivate, logger)
|
|
|
|
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,
|
|
"block_private_upstreams": cfg.blockPrivateUpstreams,
|
|
}).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
|
|
}
|
|
|
|
// envGuardBool reads a bool that turns a security guard on. Unset means
|
|
// off, but a value that does not parse turns the guard on: a typo must not
|
|
// leave a proxy that was meant to be guarded without the guard.
|
|
func envGuardBool(key string, logger *log.Logger) bool {
|
|
s := os.Getenv(key)
|
|
if s == "" {
|
|
return false
|
|
}
|
|
v, err := strconv.ParseBool(s)
|
|
if err != nil {
|
|
logger.Warnf("failed to parse %s=%q as bool, enabling it: %v", key, s, err)
|
|
return true
|
|
}
|
|
return v
|
|
}
|
|
|
|
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
|
|
}
|