mirror of
https://github.com/caddyserver/caddy.git
synced 2026-07-10 10:54:28 +00:00
Extends the http reverse-proxy transport with a webtransport boolean
that opts the upstream into WebTransport passthrough. Must be combined
with versions: ["3"]; WebTransport rides on HTTP/3 exclusively.
When enabled, Handler.ServeHTTP detects Extended CONNECT with
:protocol=webtransport early — before any of the normal round-trip
machinery — and branches to serveWebTransport, which:
1. Pulls the *webtransport.Server off caddyhttp.Server (via
WebTransportServer()) and errors out cleanly if HTTP/3 isn't
enabled on the frontend.
2. Picks a single upstream through the configured load-balancer.
No retries: a failed dial closes the client session and returns.
3. Walks the response-writer Unwrap() chain to reach the raw http3
writer and calls webtransport.Server.Upgrade to terminate the
incoming session.
4. Uses dialUpstreamWebTransport to open a session to the selected
upstream, forwarding request headers on the Extended CONNECT.
5. Runs runWebTransportPump between the two sessions and blocks
until both close.
The transport's wtTLSConfig is built at Provision time from the
existing TLS config (same path h3Transport already uses) and reused
for every session.
Tests: adds TestWebTransport_ReverseProxyEndToEnd which spins up a
single Caddy instance with two HTTP/3 servers — one proxy on :9443,
one terminating echo upstream on :9444 — and drives a real
webtransport.Dialer through the proxy to assert end-to-end
bidirectional-stream echo.
999 lines
35 KiB
Go
999 lines
35 KiB
Go
// Copyright 2015 Matthew Holt and The Caddy Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package reverseproxy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
weakrand "math/rand/v2"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pires/go-proxyproto"
|
|
"github.com/quic-go/quic-go"
|
|
"github.com/quic-go/quic-go/http3"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
"golang.org/x/net/http2"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp/headers"
|
|
"github.com/caddyserver/caddy/v2/modules/caddytls"
|
|
"github.com/caddyserver/caddy/v2/modules/internal/network"
|
|
)
|
|
|
|
func init() {
|
|
caddy.RegisterModule(HTTPTransport{})
|
|
}
|
|
|
|
// HTTPTransport is essentially a configuration wrapper for http.Transport.
|
|
// It defines a JSON structure useful when configuring the HTTP transport
|
|
// for Caddy's reverse proxy. It builds its http.Transport at Provision.
|
|
type HTTPTransport struct {
|
|
// TODO: It's possible that other transports (like fastcgi) might be
|
|
// able to borrow/use at least some of these config fields; if so,
|
|
// maybe move them into a type called CommonTransport and embed it?
|
|
|
|
// Configures the DNS resolver used to resolve the IP address of upstream hostnames.
|
|
Resolver *UpstreamResolver `json:"resolver,omitempty"`
|
|
|
|
// Configures TLS to the upstream. Setting this to an empty struct
|
|
// is sufficient to enable TLS with reasonable defaults.
|
|
TLS *TLSConfig `json:"tls,omitempty"`
|
|
|
|
// Configures HTTP Keep-Alive (enabled by default). Should only be
|
|
// necessary if rigorous testing has shown that tuning this helps
|
|
// improve performance.
|
|
KeepAlive *KeepAlive `json:"keep_alive,omitempty"`
|
|
|
|
// Whether to enable compression to upstream. Default: true
|
|
Compression *bool `json:"compression,omitempty"`
|
|
|
|
// Maximum number of connections per host. Default: 0 (no limit)
|
|
MaxConnsPerHost int `json:"max_conns_per_host,omitempty"`
|
|
|
|
// If non-empty, which PROXY protocol version to send when
|
|
// connecting to an upstream. Default: off.
|
|
ProxyProtocol string `json:"proxy_protocol,omitempty"`
|
|
|
|
// URL to the server that the HTTP transport will use to proxy
|
|
// requests to the upstream. See http.Transport.Proxy for
|
|
// information regarding supported protocols. This value takes
|
|
// precedence over `HTTP_PROXY`, etc.
|
|
//
|
|
// Providing a value to this parameter results in
|
|
// requests flowing through the reverse_proxy in the following
|
|
// way:
|
|
//
|
|
// User Agent ->
|
|
// reverse_proxy ->
|
|
// forward_proxy_url -> upstream
|
|
//
|
|
// Default: http.ProxyFromEnvironment
|
|
// DEPRECATED: Use NetworkProxyRaw|`network_proxy` instead. Subject to removal.
|
|
ForwardProxyURL string `json:"forward_proxy_url,omitempty"`
|
|
|
|
// How long to wait before timing out trying to connect to
|
|
// an upstream. Default: `3s`.
|
|
DialTimeout caddy.Duration `json:"dial_timeout,omitempty"`
|
|
|
|
// How long to wait before spawning an RFC 6555 Fast Fallback
|
|
// connection. A negative value disables this. Default: `300ms`.
|
|
FallbackDelay caddy.Duration `json:"dial_fallback_delay,omitempty"`
|
|
|
|
// How long to wait for reading response headers from server. Default: No timeout.
|
|
ResponseHeaderTimeout caddy.Duration `json:"response_header_timeout,omitempty"`
|
|
|
|
// The length of time to wait for a server's first response
|
|
// headers after fully writing the request headers if the
|
|
// request has a header "Expect: 100-continue". Default: No timeout.
|
|
ExpectContinueTimeout caddy.Duration `json:"expect_continue_timeout,omitempty"`
|
|
|
|
// The maximum bytes to read from response headers. Default: `10MiB`.
|
|
MaxResponseHeaderSize int64 `json:"max_response_header_size,omitempty"`
|
|
|
|
// The size of the write buffer in bytes. Default: `4KiB`.
|
|
WriteBufferSize int `json:"write_buffer_size,omitempty"`
|
|
|
|
// The size of the read buffer in bytes. Default: `4KiB`.
|
|
ReadBufferSize int `json:"read_buffer_size,omitempty"`
|
|
|
|
// The maximum time to wait for next read from backend. Default: no timeout.
|
|
ReadTimeout caddy.Duration `json:"read_timeout,omitempty"`
|
|
|
|
// The maximum time to wait for next write to backend. Default: no timeout.
|
|
WriteTimeout caddy.Duration `json:"write_timeout,omitempty"`
|
|
|
|
// The versions of HTTP to support. As a special case, "h2c"
|
|
// can be specified to use H2C (HTTP/2 over Cleartext) to the
|
|
// upstream (this feature is experimental and subject to
|
|
// change or removal). Default: ["1.1", "2"]
|
|
//
|
|
// EXPERIMENTAL: "3" enables HTTP/3, but it must be the only
|
|
// version specified if enabled. Additionally, HTTPS must be
|
|
// enabled to the upstream as HTTP/3 requires TLS. Subject
|
|
// to change or removal while experimental.
|
|
Versions []string `json:"versions,omitempty"`
|
|
|
|
// WebTransport enables reverse-proxying of WebTransport sessions
|
|
// (https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/) to
|
|
// the upstream. Requires Versions to be exactly ["3"]. When
|
|
// enabled, the frontend Caddy server must itself be serving HTTP/3,
|
|
// and any Extended CONNECT request with :protocol=webtransport will
|
|
// have its streams and datagrams pumped between the client and the
|
|
// upstream — bypassing the normal HTTP round-trip path.
|
|
//
|
|
// EXPERIMENTAL: subject to change or removal. The upstream
|
|
// WebTransport protocol draft is still evolving; this lands with
|
|
// whatever draft version the webtransport-go library supports at
|
|
// build time.
|
|
WebTransport bool `json:"webtransport,omitempty"`
|
|
|
|
// Specify the address to bind to when connecting to an upstream. In other words,
|
|
// it is the address the upstream sees as the remote address.
|
|
LocalAddress string `json:"local_address,omitempty"`
|
|
|
|
// The pre-configured underlying HTTP transport.
|
|
Transport *http.Transport `json:"-"`
|
|
|
|
// The module that provides the network (forward) proxy
|
|
// URL that the HTTP transport will use to proxy
|
|
// requests to the upstream. See [http.Transport.Proxy](https://pkg.go.dev/net/http#Transport.Proxy)
|
|
// for information regarding supported protocols.
|
|
//
|
|
// Providing a value to this parameter results in requests
|
|
// flowing through the reverse_proxy in the following way:
|
|
//
|
|
// User Agent ->
|
|
// reverse_proxy ->
|
|
// [proxy provided by the module] -> upstream
|
|
//
|
|
// If nil, defaults to reading the `HTTP_PROXY`,
|
|
// `HTTPS_PROXY`, and `NO_PROXY` environment variables.
|
|
NetworkProxyRaw json.RawMessage `json:"network_proxy,omitempty" caddy:"namespace=caddy.network_proxy inline_key=from"`
|
|
|
|
h3Transport *http3.Transport // TODO: EXPERIMENTAL (May 2024)
|
|
quicTransport *quic.Transport // used by h3Transport if sni placeholder is used, otherwise nil
|
|
}
|
|
|
|
// CaddyModule returns the Caddy module information.
|
|
func (HTTPTransport) CaddyModule() caddy.ModuleInfo {
|
|
return caddy.ModuleInfo{
|
|
ID: "http.reverse_proxy.transport.http",
|
|
New: func() caddy.Module { return new(HTTPTransport) },
|
|
}
|
|
}
|
|
|
|
var (
|
|
allowedVersions = []string{"1.1", "2", "h2c", "3"}
|
|
allowedVersionsString = strings.Join(allowedVersions, ", ")
|
|
)
|
|
|
|
// Provision sets up h.Transport with a *http.Transport
|
|
// that is ready to use.
|
|
func (h *HTTPTransport) Provision(ctx caddy.Context) error {
|
|
if len(h.Versions) == 0 {
|
|
h.Versions = []string{"1.1", "2"}
|
|
}
|
|
// some users may provide http versions not recognized by caddy, instead of trying to
|
|
// guess the version, we just error out and let the user fix their config
|
|
// see: https://github.com/caddyserver/caddy/issues/7111
|
|
for _, v := range h.Versions {
|
|
if !slices.Contains(allowedVersions, v) {
|
|
return fmt.Errorf("unsupported HTTP version: %s, supported version: %s", v, allowedVersionsString)
|
|
}
|
|
}
|
|
|
|
rt, err := h.NewTransport(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.Transport = rt
|
|
|
|
return nil
|
|
}
|
|
|
|
// NewTransport builds a standard-lib-compatible http.Transport value from h.
|
|
func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, error) {
|
|
// Set keep-alive defaults if it wasn't otherwise configured
|
|
if h.KeepAlive == nil {
|
|
h.KeepAlive = new(KeepAlive)
|
|
}
|
|
if h.KeepAlive.ProbeInterval == 0 {
|
|
h.KeepAlive.ProbeInterval = caddy.Duration(30 * time.Second)
|
|
}
|
|
if h.KeepAlive.IdleConnTimeout == 0 {
|
|
h.KeepAlive.IdleConnTimeout = caddy.Duration(2 * time.Minute)
|
|
}
|
|
if h.KeepAlive.MaxIdleConnsPerHost == 0 {
|
|
h.KeepAlive.MaxIdleConnsPerHost = 32 // seems about optimal, see #2805
|
|
}
|
|
|
|
// Set a relatively short default dial timeout.
|
|
// This is helpful to make load-balancer retries more speedy.
|
|
if h.DialTimeout == 0 {
|
|
h.DialTimeout = caddy.Duration(3 * time.Second)
|
|
}
|
|
|
|
dialer := &net.Dialer{
|
|
Timeout: time.Duration(h.DialTimeout),
|
|
FallbackDelay: time.Duration(h.FallbackDelay),
|
|
}
|
|
|
|
if h.LocalAddress != "" {
|
|
netaddr, err := caddy.ParseNetworkAddressWithDefaults(h.LocalAddress, "tcp", 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if netaddr.PortRangeSize() > 1 {
|
|
return nil, fmt.Errorf("local_address must be a single address, not a port range")
|
|
}
|
|
switch netaddr.Network {
|
|
case "tcp", "tcp4", "tcp6":
|
|
dialer.LocalAddr, err = net.ResolveTCPAddr(netaddr.Network, netaddr.JoinHostPort(0))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case "unix", "unixgram", "unixpacket":
|
|
dialer.LocalAddr, err = net.ResolveUnixAddr(netaddr.Network, netaddr.JoinHostPort(0))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case "udp", "udp4", "udp6":
|
|
return nil, fmt.Errorf("local_address must be a TCP address, not a UDP address")
|
|
default:
|
|
return nil, fmt.Errorf("unsupported network")
|
|
}
|
|
}
|
|
if h.Resolver != nil {
|
|
err := h.Resolver.ParseAddresses()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d := &net.Dialer{
|
|
Timeout: time.Duration(h.DialTimeout),
|
|
FallbackDelay: time.Duration(h.FallbackDelay),
|
|
}
|
|
dialer.Resolver = &net.Resolver{
|
|
PreferGo: true,
|
|
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
//nolint:gosec
|
|
addr := h.Resolver.netAddrs[weakrand.IntN(len(h.Resolver.netAddrs))]
|
|
return d.DialContext(ctx, addr.Network, addr.JoinHostPort(0))
|
|
},
|
|
}
|
|
}
|
|
|
|
dialContext := func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
// The network is usually tcp, and the address is the host in http.Request.URL.Host
|
|
// and that's been overwritten in directRequest
|
|
// However, if proxy is used according to http.ProxyFromEnvironment or proxy providers,
|
|
// address will be the address of the proxy server.
|
|
|
|
// This means we can safely use the address in dialInfo if proxy is not used (the address and network will be same any way)
|
|
// or if the upstream is unix (because there is no way socks or http proxy can be used for unix address).
|
|
if dialInfo, ok := GetDialInfo(ctx); ok {
|
|
if caddyhttp.GetVar(ctx, proxyVarKey) == nil || strings.HasPrefix(dialInfo.Network, "unix") {
|
|
network = dialInfo.Network
|
|
address = dialInfo.Address
|
|
}
|
|
}
|
|
|
|
conn, err := dialer.DialContext(ctx, network, address)
|
|
if err != nil {
|
|
// identify this error as one that occurred during
|
|
// dialing, which can be important when trying to
|
|
// decide whether to retry a request
|
|
return nil, DialError{err}
|
|
}
|
|
|
|
if h.ProxyProtocol != "" {
|
|
proxyProtocolInfo, ok := caddyhttp.GetVar(ctx, proxyProtocolInfoVarKey).(ProxyProtocolInfo)
|
|
if !ok {
|
|
return nil, fmt.Errorf("failed to get proxy protocol info from context")
|
|
}
|
|
var proxyv byte
|
|
switch h.ProxyProtocol {
|
|
case "v1":
|
|
proxyv = 1
|
|
case "v2":
|
|
proxyv = 2
|
|
default:
|
|
return nil, fmt.Errorf("unexpected proxy protocol version")
|
|
}
|
|
|
|
// The src and dst have to be of the same address family. As we don't know the original
|
|
// dst address (it's kind of impossible to know) and this address is generally of very
|
|
// little interest, we just set it to all zeros.
|
|
var destAddr net.Addr
|
|
switch {
|
|
case proxyProtocolInfo.AddrPort.Addr().Is4():
|
|
destAddr = &net.TCPAddr{
|
|
IP: net.IPv4zero,
|
|
}
|
|
case proxyProtocolInfo.AddrPort.Addr().Is6():
|
|
destAddr = &net.TCPAddr{
|
|
IP: net.IPv6zero,
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unexpected remote addr type in proxy protocol info")
|
|
}
|
|
sourceAddr := &net.TCPAddr{
|
|
IP: proxyProtocolInfo.AddrPort.Addr().AsSlice(),
|
|
Port: int(proxyProtocolInfo.AddrPort.Port()),
|
|
Zone: proxyProtocolInfo.AddrPort.Addr().Zone(),
|
|
}
|
|
header := proxyproto.HeaderProxyFromAddrs(proxyv, sourceAddr, destAddr)
|
|
|
|
// retain the log message structure
|
|
switch h.ProxyProtocol {
|
|
case "v1":
|
|
caddyCtx.Logger().Debug("sending proxy protocol header v1", zap.Any("header", header))
|
|
case "v2":
|
|
caddyCtx.Logger().Debug("sending proxy protocol header v2", zap.Any("header", header))
|
|
}
|
|
|
|
_, err = header.WriteTo(conn)
|
|
if err != nil {
|
|
// identify this error as one that occurred during
|
|
// dialing, which can be important when trying to
|
|
// decide whether to retry a request
|
|
return nil, DialError{err}
|
|
}
|
|
}
|
|
|
|
// if read/write timeouts are configured and this is a TCP connection,
|
|
// enforce the timeouts by wrapping the connection with our own type
|
|
if tcpConn, ok := conn.(*net.TCPConn); ok && (h.ReadTimeout > 0 || h.WriteTimeout > 0) {
|
|
conn = &tcpRWTimeoutConn{
|
|
TCPConn: tcpConn,
|
|
readTimeout: time.Duration(h.ReadTimeout),
|
|
writeTimeout: time.Duration(h.WriteTimeout),
|
|
logger: caddyCtx.Logger(),
|
|
}
|
|
}
|
|
|
|
return conn, nil
|
|
}
|
|
|
|
// negotiate any HTTP/SOCKS proxy for the HTTP transport
|
|
proxy := http.ProxyFromEnvironment
|
|
if h.ForwardProxyURL != "" {
|
|
caddyCtx.Logger().Warn("forward_proxy_url is deprecated; use network_proxy instead")
|
|
u := network.ProxyFromURL{URL: h.ForwardProxyURL}
|
|
h.NetworkProxyRaw = caddyconfig.JSONModuleObject(u, "from", "url", nil)
|
|
}
|
|
if len(h.NetworkProxyRaw) != 0 {
|
|
proxyMod, err := caddyCtx.LoadModule(h, "NetworkProxyRaw")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load network_proxy module: %v", err)
|
|
}
|
|
if m, ok := proxyMod.(caddy.ProxyFuncProducer); ok {
|
|
proxy = m.ProxyFunc()
|
|
} else {
|
|
return nil, fmt.Errorf("network_proxy module is not `(func(*http.Request) (*url.URL, error))``")
|
|
}
|
|
}
|
|
// we need to keep track if a proxy is used for a request
|
|
proxyWrapper := func(req *http.Request) (*url.URL, error) {
|
|
if proxy == nil {
|
|
return nil, nil
|
|
}
|
|
u, err := proxy(req)
|
|
if u == nil || err != nil {
|
|
return u, err
|
|
}
|
|
// there must be a proxy for this request
|
|
caddyhttp.SetVar(req.Context(), proxyVarKey, u)
|
|
return u, nil
|
|
}
|
|
|
|
rt := &http.Transport{
|
|
Proxy: proxyWrapper,
|
|
DialContext: dialContext,
|
|
MaxConnsPerHost: h.MaxConnsPerHost,
|
|
ResponseHeaderTimeout: time.Duration(h.ResponseHeaderTimeout),
|
|
ExpectContinueTimeout: time.Duration(h.ExpectContinueTimeout),
|
|
MaxResponseHeaderBytes: h.MaxResponseHeaderSize,
|
|
WriteBufferSize: h.WriteBufferSize,
|
|
ReadBufferSize: h.ReadBufferSize,
|
|
}
|
|
|
|
if h.TLS != nil {
|
|
rt.TLSHandshakeTimeout = time.Duration(h.TLS.HandshakeTimeout)
|
|
var err error
|
|
rt.TLSClientConfig, err = h.TLS.MakeTLSClientConfig(caddyCtx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("making TLS client config: %v", err)
|
|
}
|
|
|
|
serverNameHasPlaceholder := strings.Contains(h.TLS.ServerName, "{")
|
|
|
|
// We need to use custom DialTLSContext if:
|
|
// 1. ServerName has a placeholder that needs to be replaced at request-time, OR
|
|
// 2. ProxyProtocol is enabled, because req.URL.Host is modified to include
|
|
// client address info with "->" separator which breaks Go's address parsing
|
|
if serverNameHasPlaceholder || h.ProxyProtocol != "" {
|
|
rt.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
// reuses the dialer from above to establish a plaintext connection
|
|
conn, err := dialContext(ctx, network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// but add our own handshake logic
|
|
tlsConfig := rt.TLSClientConfig.Clone()
|
|
if serverNameHasPlaceholder {
|
|
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
|
|
tlsConfig.ServerName = repl.ReplaceAll(tlsConfig.ServerName, "")
|
|
}
|
|
|
|
// h1 only
|
|
if caddyhttp.GetVar(ctx, tlsH1OnlyVarKey) == true {
|
|
// stdlib does this
|
|
// https://github.com/golang/go/blob/4837fbe4145cd47b43eed66fee9eed9c2b988316/src/net/http/transport.go#L1701
|
|
tlsConfig.NextProtos = nil
|
|
}
|
|
|
|
tlsConn := tls.Client(conn, tlsConfig)
|
|
|
|
// complete the handshake before returning the connection
|
|
if rt.TLSHandshakeTimeout != 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeoutCause(ctx, rt.TLSHandshakeTimeout, fmt.Errorf("HTTP transport TLS handshake %ds timeout", int(rt.TLSHandshakeTimeout.Seconds())))
|
|
defer cancel()
|
|
}
|
|
err = tlsConn.HandshakeContext(ctx)
|
|
if err != nil {
|
|
_ = tlsConn.Close()
|
|
return nil, err
|
|
}
|
|
return tlsConn, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if h.KeepAlive != nil {
|
|
// according to https://pkg.go.dev/net#Dialer.KeepAliveConfig,
|
|
// KeepAlive is ignored if KeepAliveConfig.Enable is true.
|
|
// If configured to 0, a system-dependent default is used.
|
|
// To disable tcp keepalive, choose a negative value,
|
|
// so KeepAliveConfig.Enable is false and KeepAlive is negative.
|
|
|
|
// This is different from http keepalive where a tcp connection
|
|
// can transfer multiple http requests/responses.
|
|
dialer.KeepAlive = time.Duration(h.KeepAlive.ProbeInterval)
|
|
dialer.KeepAliveConfig = net.KeepAliveConfig{
|
|
Enable: h.KeepAlive.ProbeInterval > 0,
|
|
Interval: time.Duration(h.KeepAlive.ProbeInterval),
|
|
}
|
|
if h.KeepAlive.Enabled != nil {
|
|
rt.DisableKeepAlives = !*h.KeepAlive.Enabled
|
|
}
|
|
rt.MaxIdleConns = h.KeepAlive.MaxIdleConns
|
|
rt.MaxIdleConnsPerHost = h.KeepAlive.MaxIdleConnsPerHost
|
|
rt.IdleConnTimeout = time.Duration(h.KeepAlive.IdleConnTimeout)
|
|
}
|
|
|
|
if h.Compression != nil {
|
|
rt.DisableCompression = !*h.Compression
|
|
}
|
|
|
|
// configure HTTP/3 transport if enabled; however, this does not
|
|
// automatically fall back to lower versions like most web browsers
|
|
// do (that'd add latency and complexity, besides, we expect that
|
|
// site owners control the backends), so it must be exclusive
|
|
if len(h.Versions) == 1 && h.Versions[0] == "3" {
|
|
h.h3Transport = new(http3.Transport)
|
|
if h.TLS != nil {
|
|
var err error
|
|
h.h3Transport.TLSClientConfig, err = h.TLS.MakeTLSClientConfig(caddyCtx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("making TLS client config for HTTP/3 transport: %v", err)
|
|
}
|
|
|
|
if strings.Contains(h.TLS.ServerName, "{") {
|
|
// copied from quic-go
|
|
udpConn, err := net.ListenUDP("udp", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("making udp socket for HTTP/3 transport: %v", err)
|
|
}
|
|
h.quicTransport = &quic.Transport{Conn: udpConn}
|
|
h.h3Transport.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
|
|
// tlsCfg is already cloned from h3Transport.TLSClientConfig
|
|
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
|
|
tlsCfg.ServerName = repl.ReplaceAll(tlsCfg.ServerName, "")
|
|
udpAddr, err := resolveUDPAddr(ctx, "udp", addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return h.quicTransport.DialEarly(ctx, udpAddr, tlsCfg, cfg)
|
|
}
|
|
}
|
|
}
|
|
} else if len(h.Versions) > 1 && slices.Contains(h.Versions, "3") {
|
|
return nil, fmt.Errorf("if HTTP/3 is enabled to the upstream, no other HTTP versions are supported")
|
|
}
|
|
|
|
// WebTransport rides on HTTP/3 exclusively and reuses the TLS client
|
|
// config built for h3Transport above.
|
|
if h.WebTransport && !(len(h.Versions) == 1 && h.Versions[0] == "3") {
|
|
return nil, fmt.Errorf("webtransport requires versions to be exactly [\"3\"]")
|
|
}
|
|
|
|
// if h2/c is enabled, configure it explicitly
|
|
if slices.Contains(h.Versions, "2") || slices.Contains(h.Versions, "h2c") {
|
|
if err := http2.ConfigureTransport(rt); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// DisableCompression from h2 is configured by http2.ConfigureTransport
|
|
// Likewise, DisableKeepAlives from h1 is used too.
|
|
|
|
// Protocols field is only used when the request is not using TLS,
|
|
// http1/2 over tls is still allowed
|
|
if slices.Contains(h.Versions, "h2c") {
|
|
rt.Protocols = new(http.Protocols)
|
|
rt.Protocols.SetUnencryptedHTTP2(true)
|
|
rt.Protocols.SetHTTP1(false)
|
|
}
|
|
}
|
|
|
|
return rt, nil
|
|
}
|
|
|
|
// TODO: EXPERIMENTAL (May 2025)
|
|
// copied from quic-go
|
|
func resolveUDPAddr(ctx context.Context, network, addr string) (*net.UDPAddr, error) {
|
|
host, portStr, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
port, err := net.LookupPort(network, portStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolver := net.DefaultResolver
|
|
ipAddrs, err := resolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
addrs := addrList(ipAddrs)
|
|
ip := addrs.forResolve(network, addr)
|
|
return &net.UDPAddr{IP: ip.IP, Port: port, Zone: ip.Zone}, nil
|
|
}
|
|
|
|
// TODO: EXPERIMENTAL (May 2025)
|
|
// copied from quic-go
|
|
// An addrList represents a list of network endpoint addresses.
|
|
// Copy from [net.addrList] and change type from [net.Addr] to [net.IPAddr]
|
|
type addrList []net.IPAddr
|
|
|
|
// isIPv4 reports whether addr contains an IPv4 address.
|
|
func isIPv4(addr net.IPAddr) bool {
|
|
return addr.IP.To4() != nil
|
|
}
|
|
|
|
// isNotIPv4 reports whether addr does not contain an IPv4 address.
|
|
func isNotIPv4(addr net.IPAddr) bool { return !isIPv4(addr) }
|
|
|
|
// forResolve returns the most appropriate address in address for
|
|
// a call to ResolveTCPAddr, ResolveUDPAddr, or ResolveIPAddr.
|
|
// IPv4 is preferred, unless addr contains an IPv6 literal.
|
|
func (addrs addrList) forResolve(network, addr string) net.IPAddr {
|
|
var want6 bool
|
|
switch network {
|
|
case "ip":
|
|
// IPv6 literal (addr does NOT contain a port)
|
|
want6 = strings.ContainsRune(addr, ':')
|
|
case "tcp", "udp":
|
|
// IPv6 literal. (addr contains a port, so look for '[')
|
|
want6 = strings.ContainsRune(addr, '[')
|
|
}
|
|
if want6 {
|
|
return addrs.first(isNotIPv4)
|
|
}
|
|
return addrs.first(isIPv4)
|
|
}
|
|
|
|
// first returns the first address which satisfies strategy, or if
|
|
// none do, then the first address of any kind.
|
|
func (addrs addrList) first(strategy func(net.IPAddr) bool) net.IPAddr {
|
|
for _, addr := range addrs {
|
|
if strategy(addr) {
|
|
return addr
|
|
}
|
|
}
|
|
return addrs[0]
|
|
}
|
|
|
|
// RequestHeaderOps implements TransportHeaderOpsProvider. It returns header
|
|
// operations for requests when the transport's configuration indicates they
|
|
// should be applied. In particular, when TLS is enabled for this transport,
|
|
// return an operation to set the Host header to the upstream host:port
|
|
// placeholder so HTTPS upstreams get the proper Host by default.
|
|
//
|
|
// Note: this is a provision-time hook; the Handler will call this during
|
|
// its Provision and cache the resulting HeaderOps. The HeaderOps are
|
|
// applied per-request (so placeholders are expanded at request time).
|
|
func (h *HTTPTransport) RequestHeaderOps() *headers.HeaderOps {
|
|
// If TLS is not configured for this transport, don't inject Host
|
|
// defaults. TLS being non-nil indicates HTTPS to the upstream.
|
|
if h.TLS == nil {
|
|
return nil
|
|
}
|
|
return &headers.HeaderOps{
|
|
Set: http.Header{
|
|
"Host": []string{"{http.reverse_proxy.upstream.hostport}"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// RoundTrip implements http.RoundTripper.
|
|
func (h *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
h.SetScheme(req)
|
|
|
|
// use HTTP/3 if enabled (TODO: This is EXPERIMENTAL)
|
|
if h.h3Transport != nil {
|
|
return h.h3Transport.RoundTrip(req)
|
|
}
|
|
|
|
return h.Transport.RoundTrip(req)
|
|
}
|
|
|
|
// SetScheme ensures that the outbound request req
|
|
// has the scheme set in its URL; the underlying
|
|
// http.Transport requires a scheme to be set.
|
|
//
|
|
// This method may be used by other transport modules
|
|
// that wrap/use this one.
|
|
func (h *HTTPTransport) SetScheme(req *http.Request) {
|
|
if req.URL.Scheme != "" {
|
|
return
|
|
}
|
|
if h.shouldUseTLS(req) {
|
|
req.URL.Scheme = "https"
|
|
} else {
|
|
req.URL.Scheme = "http"
|
|
}
|
|
}
|
|
|
|
// shouldUseTLS returns true if TLS should be used for req.
|
|
func (h *HTTPTransport) shouldUseTLS(req *http.Request) bool {
|
|
if h.TLS == nil {
|
|
return false
|
|
}
|
|
|
|
port := req.URL.Port()
|
|
return !slices.Contains(h.TLS.ExceptPorts, port)
|
|
}
|
|
|
|
// TLSEnabled returns true if TLS is enabled.
|
|
func (h HTTPTransport) TLSEnabled() bool {
|
|
return h.TLS != nil
|
|
}
|
|
|
|
// EnableTLS enables TLS on the transport.
|
|
func (h *HTTPTransport) EnableTLS(base *TLSConfig) error {
|
|
h.TLS = base
|
|
return nil
|
|
}
|
|
|
|
// EnableH2C enables H2C (HTTP/2 over Cleartext) on the transport.
|
|
func (h *HTTPTransport) EnableH2C() error {
|
|
h.Versions = []string{"h2c", "2"}
|
|
return nil
|
|
}
|
|
|
|
// OverrideHealthCheckScheme overrides the scheme of the given URL
|
|
// used for health checks.
|
|
func (h HTTPTransport) OverrideHealthCheckScheme(base *url.URL, port string) {
|
|
// if tls is enabled and the port isn't in the except list, use HTTPs
|
|
if h.TLSEnabled() && !slices.Contains(h.TLS.ExceptPorts, port) {
|
|
base.Scheme = "https"
|
|
}
|
|
}
|
|
|
|
// ProxyProtocolEnabled returns true if proxy protocol is enabled.
|
|
func (h HTTPTransport) ProxyProtocolEnabled() bool {
|
|
return h.ProxyProtocol != ""
|
|
}
|
|
|
|
// Cleanup implements caddy.CleanerUpper and closes any idle connections.
|
|
func (h HTTPTransport) Cleanup() error {
|
|
if h.Transport == nil {
|
|
return nil
|
|
}
|
|
h.Transport.CloseIdleConnections()
|
|
// h3 related cleanup, errors are ignored as nothing can be done.
|
|
// TODO: log these errors if any
|
|
if h.h3Transport != nil {
|
|
h.h3Transport.CloseIdleConnections()
|
|
_ = h.h3Transport.Close()
|
|
if h.quicTransport != nil {
|
|
_ = h.quicTransport.Close()
|
|
_ = h.quicTransport.Conn.Close()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TLSConfig holds configuration related to the TLS configuration for the
|
|
// transport/client.
|
|
type TLSConfig struct {
|
|
// Certificate authority module which provides the certificate pool of trusted certificates
|
|
CARaw json.RawMessage `json:"ca,omitempty" caddy:"namespace=tls.ca_pool.source inline_key=provider"`
|
|
|
|
// Deprecated: Use the `ca` field with the `tls.ca_pool.source.inline` module instead.
|
|
// Optional list of base64-encoded DER-encoded CA certificates to trust.
|
|
RootCAPool []string `json:"root_ca_pool,omitempty"`
|
|
|
|
// Deprecated: Use the `ca` field with the `tls.ca_pool.source.file` module instead.
|
|
// List of PEM-encoded CA certificate files to add to the same trust
|
|
// store as RootCAPool (or root_ca_pool in the JSON).
|
|
RootCAPEMFiles []string `json:"root_ca_pem_files,omitempty"`
|
|
|
|
// PEM-encoded client certificate filename to present to servers.
|
|
ClientCertificateFile string `json:"client_certificate_file,omitempty"`
|
|
|
|
// PEM-encoded key to use with the client certificate.
|
|
ClientCertificateKeyFile string `json:"client_certificate_key_file,omitempty"`
|
|
|
|
// If specified, Caddy will use and automate a client certificate
|
|
// with this subject name.
|
|
ClientCertificateAutomate string `json:"client_certificate_automate,omitempty"`
|
|
|
|
// If true, TLS verification of server certificates will be disabled.
|
|
// This is insecure and may be removed in the future. Do not use this
|
|
// option except in testing or local development environments.
|
|
InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
|
|
|
|
// The duration to allow a TLS handshake to a server. Default: No timeout.
|
|
HandshakeTimeout caddy.Duration `json:"handshake_timeout,omitempty"`
|
|
|
|
// The server name used when verifying the certificate received in the TLS
|
|
// handshake. By default, this will use the upstream address' host part.
|
|
// You only need to override this if your upstream address does not match the
|
|
// certificate the upstream is likely to use. For example if the upstream
|
|
// address is an IP address, then you would need to configure this to the
|
|
// hostname being served by the upstream server. Currently, this does not
|
|
// support placeholders because the TLS config is not provisioned on each
|
|
// connection, so a static value must be used.
|
|
ServerName string `json:"server_name,omitempty"`
|
|
|
|
// TLS renegotiation level. TLS renegotiation is the act of performing
|
|
// subsequent handshakes on a connection after the first.
|
|
// The level can be:
|
|
// - "never": (the default) disables renegotiation.
|
|
// - "once": allows a remote server to request renegotiation once per connection.
|
|
// - "freely": allows a remote server to repeatedly request renegotiation.
|
|
Renegotiation string `json:"renegotiation,omitempty"`
|
|
|
|
// Skip TLS ports specifies a list of upstream ports on which TLS should not be
|
|
// attempted even if it is configured. Handy when using dynamic upstreams that
|
|
// return HTTP and HTTPS endpoints too.
|
|
// When specified, TLS will automatically be configured on the transport.
|
|
// The value can be a list of any valid tcp port numbers, default empty.
|
|
ExceptPorts []string `json:"except_ports,omitempty"`
|
|
|
|
// The list of elliptic curves to support. Caddy's
|
|
// defaults are modern and secure.
|
|
Curves []string `json:"curves,omitempty"`
|
|
}
|
|
|
|
// MakeTLSClientConfig returns a tls.Config usable by a client to a backend.
|
|
// If there is no custom TLS configuration, a nil config may be returned.
|
|
func (t *TLSConfig) MakeTLSClientConfig(ctx caddy.Context) (*tls.Config, error) {
|
|
cfg := new(tls.Config)
|
|
|
|
// client auth
|
|
if t.ClientCertificateFile != "" && t.ClientCertificateKeyFile == "" {
|
|
return nil, fmt.Errorf("client_certificate_file specified without client_certificate_key_file")
|
|
}
|
|
if t.ClientCertificateFile == "" && t.ClientCertificateKeyFile != "" {
|
|
return nil, fmt.Errorf("client_certificate_key_file specified without client_certificate_file")
|
|
}
|
|
if t.ClientCertificateFile != "" && t.ClientCertificateKeyFile != "" {
|
|
cert, err := tls.LoadX509KeyPair(t.ClientCertificateFile, t.ClientCertificateKeyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading client certificate key pair: %v", err)
|
|
}
|
|
cfg.Certificates = []tls.Certificate{cert}
|
|
}
|
|
if t.ClientCertificateAutomate != "" {
|
|
// TODO: use or enable ctx.IdentityCredentials() ...
|
|
tlsAppIface, err := ctx.App("tls")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting tls app: %v", err)
|
|
}
|
|
tlsApp := tlsAppIface.(*caddytls.TLS)
|
|
err = tlsApp.Manage(map[string]struct{}{t.ClientCertificateAutomate: {}})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("managing client certificate: %v", err)
|
|
}
|
|
cfg.GetClientCertificate = func(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
|
certs := caddytls.AllMatchingCertificates(t.ClientCertificateAutomate)
|
|
var err error
|
|
for _, cert := range certs {
|
|
certCertificate := cert.Certificate // avoid taking address of iteration variable (gosec warning)
|
|
err = cri.SupportsCertificate(&certCertificate)
|
|
if err == nil {
|
|
return &cert.Certificate, nil
|
|
}
|
|
}
|
|
if err == nil {
|
|
err = fmt.Errorf("no client certificate found for automate name: %s", t.ClientCertificateAutomate)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// trusted root CAs
|
|
if len(t.RootCAPool) > 0 || len(t.RootCAPEMFiles) > 0 {
|
|
ctx.Logger().Warn("root_ca_pool and root_ca_pem_files are deprecated. Use one of the tls.ca_pool.source modules instead")
|
|
rootPool := x509.NewCertPool()
|
|
for _, encodedCACert := range t.RootCAPool {
|
|
caCert, err := decodeBase64DERCert(encodedCACert)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing CA certificate: %v", err)
|
|
}
|
|
rootPool.AddCert(caCert)
|
|
}
|
|
for _, pemFile := range t.RootCAPEMFiles {
|
|
pemData, err := os.ReadFile(pemFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed reading ca cert: %v", err)
|
|
}
|
|
rootPool.AppendCertsFromPEM(pemData)
|
|
}
|
|
cfg.RootCAs = rootPool
|
|
}
|
|
|
|
if t.CARaw != nil {
|
|
if len(t.RootCAPool) > 0 || len(t.RootCAPEMFiles) > 0 {
|
|
return nil, fmt.Errorf("conflicting config for Root CA pool")
|
|
}
|
|
caRaw, err := ctx.LoadModule(t, "CARaw")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load ca module: %v", err)
|
|
}
|
|
ca, ok := caRaw.(caddytls.CA)
|
|
if !ok {
|
|
return nil, fmt.Errorf("CA module '%s' is not a certificate pool provider", ca)
|
|
}
|
|
cfg.RootCAs = ca.CertPool()
|
|
}
|
|
|
|
// Renegotiation
|
|
switch t.Renegotiation {
|
|
case "never", "":
|
|
cfg.Renegotiation = tls.RenegotiateNever
|
|
case "once":
|
|
cfg.Renegotiation = tls.RenegotiateOnceAsClient
|
|
case "freely":
|
|
cfg.Renegotiation = tls.RenegotiateFreelyAsClient
|
|
default:
|
|
return nil, fmt.Errorf("invalid TLS renegotiation level: %v", t.Renegotiation)
|
|
}
|
|
|
|
// override for the server name used verify the TLS handshake
|
|
cfg.ServerName = t.ServerName
|
|
|
|
// throw all security out the window
|
|
cfg.InsecureSkipVerify = t.InsecureSkipVerify
|
|
|
|
curvesAdded := make(map[tls.CurveID]struct{})
|
|
for _, curveName := range t.Curves {
|
|
curveID := caddytls.SupportedCurves[curveName]
|
|
if _, ok := curvesAdded[curveID]; !ok {
|
|
curvesAdded[curveID] = struct{}{}
|
|
cfg.CurvePreferences = append(cfg.CurvePreferences, curveID)
|
|
}
|
|
}
|
|
|
|
// only return a config if it's not empty
|
|
if reflect.DeepEqual(cfg, new(tls.Config)) {
|
|
return nil, nil
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// KeepAlive holds configuration pertaining to HTTP Keep-Alive.
|
|
type KeepAlive struct {
|
|
// Whether HTTP Keep-Alive is enabled. Default: `true`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
|
|
// How often to probe for liveness. Default: `30s`.
|
|
ProbeInterval caddy.Duration `json:"probe_interval,omitempty"`
|
|
|
|
// Maximum number of idle connections. Default: `0`, which means no limit.
|
|
MaxIdleConns int `json:"max_idle_conns,omitempty"`
|
|
|
|
// Maximum number of idle connections per host. Default: `32`.
|
|
MaxIdleConnsPerHost int `json:"max_idle_conns_per_host,omitempty"`
|
|
|
|
// How long connections should be kept alive when idle. Default: `2m`.
|
|
IdleConnTimeout caddy.Duration `json:"idle_timeout,omitempty"`
|
|
}
|
|
|
|
// tcpRWTimeoutConn enforces read/write timeouts for a TCP connection.
|
|
// If it fails to set deadlines, the error is logged but does not abort
|
|
// the read/write attempt (ignoring the error is consistent with what
|
|
// the standard library does: https://github.com/golang/go/blob/c5da4fb7ac5cb7434b41fc9a1df3bee66c7f1a4d/src/net/http/server.go#L981-L986)
|
|
type tcpRWTimeoutConn struct {
|
|
*net.TCPConn
|
|
readTimeout, writeTimeout time.Duration
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func (c *tcpRWTimeoutConn) Read(b []byte) (int, error) {
|
|
if c.readTimeout > 0 {
|
|
err := c.TCPConn.SetReadDeadline(time.Now().Add(c.readTimeout))
|
|
if err != nil {
|
|
if ce := c.logger.Check(zapcore.ErrorLevel, "failed to set read deadline"); ce != nil {
|
|
ce.Write(zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
return c.TCPConn.Read(b)
|
|
}
|
|
|
|
func (c *tcpRWTimeoutConn) Write(b []byte) (int, error) {
|
|
if c.writeTimeout > 0 {
|
|
err := c.TCPConn.SetWriteDeadline(time.Now().Add(c.writeTimeout))
|
|
if err != nil {
|
|
if ce := c.logger.Check(zapcore.ErrorLevel, "failed to set write deadline"); ce != nil {
|
|
ce.Write(zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
return c.TCPConn.Write(b)
|
|
}
|
|
|
|
// decodeBase64DERCert base64-decodes, then DER-decodes, certStr.
|
|
func decodeBase64DERCert(certStr string) (*x509.Certificate, error) {
|
|
// decode base64
|
|
derBytes, err := base64.StdEncoding.DecodeString(certStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// parse the DER-encoded certificate
|
|
return x509.ParseCertificate(derBytes)
|
|
}
|
|
|
|
// Interface guards
|
|
var (
|
|
_ caddy.Provisioner = (*HTTPTransport)(nil)
|
|
_ http.RoundTripper = (*HTTPTransport)(nil)
|
|
_ caddy.CleanerUpper = (*HTTPTransport)(nil)
|
|
_ TLSTransport = (*HTTPTransport)(nil)
|
|
_ H2CTransport = (*HTTPTransport)(nil)
|
|
_ HealthCheckSchemeOverriderTransport = (*HTTPTransport)(nil)
|
|
_ ProxyProtocolTransport = (*HTTPTransport)(nil)
|
|
)
|