Files
netbird/proxy/internal/roundtrip/transport.go
T
Brad Ison f400f4bee8 [proxy] Optionally refuse private addresses on direct-upstream dials (#7913)
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.
2026-10-02 12:02:06 +02:00

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
}