diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 3feea435e..420f0f765 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -155,6 +155,12 @@ modules: # Whether to enable HTTP2. [ enable_http2: | default: true ] + # Whether to enable HTTP3. + # Note: When enable_http3 is true, valid_http_versions must not include HTTP/2.0 + # as HTTP/3 and HTTP/2 cannot be used together. Additionally, when enable_http3 + # is true, enable_http2 must be set to false. + [ enable_http3: | default: false ] + # The IP protocol of the HTTP probe (ip4, ip6). [ preferred_ip_protocol: | default = "ip6" ] [ ip_protocol_fallback: | default = true ] diff --git a/blackbox.yml b/blackbox.yml index a091a4cd2..44f19b905 100644 --- a/blackbox.yml +++ b/blackbox.yml @@ -60,3 +60,10 @@ modules: timeout: 5s icmp: ttl: 5 + http_3xx: + prober: http + http: + preferred_ip_protocol: "ip4" + enable_http3: true + enable_http2: false + valid_http_versions: ["HTTP/3.0"] diff --git a/config/config.go b/config/config.go index 246666051..7e14a7a85 100644 --- a/config/config.go +++ b/config/config.go @@ -136,6 +136,11 @@ func (sc *SafeConfig) ReloadConfig(confFile string, logger *slog.Logger) (err er logger.Warn("no_follow_redirects is deprecated and will be removed in the next release. It is replaced by follow_redirects.", "module", name) } } + + // Warn if HTTP/3 is enabled - it requires HTTPS targets + if module.HTTP.UseHTTP3 && logger != nil { + logger.Warn("HTTP/3 is enabled for this module. HTTP targets will be automatically converted to HTTPS during probing. Consider using HTTPS targets directly in your configuration.", "module", name) + } } sc.Lock() @@ -291,6 +296,7 @@ type HTTPProbe struct { HTTPClientConfig config.HTTPClientConfig `yaml:"http_client_config,inline"` Compression string `yaml:"compression,omitempty"` BodySizeLimit units.Base2Bytes `yaml:"body_size_limit,omitempty"` + UseHTTP3 bool `yaml:"enable_http3,omitempty"` } type GRPCProbe struct { @@ -419,6 +425,28 @@ func (s *HTTPProbe) UnmarshalYAML(unmarshal func(interface{}) error) error { } } + if !s.UseHTTP3 { + for _, version := range s.ValidHTTPVersions { + if version == "HTTP/3.0" { + return errors.New("HTTP/3 cannot be used as a valid HTTP version when enable_http3 is false") + } + } + } + + if s.UseHTTP3 { + // When HTTP/3 is enabled, HTTP/2.0 and HTTP/1.1 must not be in valid_http_versions + for _, version := range s.ValidHTTPVersions { + if version == "HTTP/2.0" || version == "HTTP/1.1" { + return errors.New("HTTP/3 and HTTP/2.0/1.1 cannot be used together - only HTTP/3.0 is allowed when enable_http3 is true") + } + } + + // When HTTP/3 is enabled, HTTP/2 must be explicitly disabled + if s.HTTPClientConfig.EnableHTTP2 { + return errors.New("when enable_http3 is true, enable_http2 must be set to false") + } + } + return nil } diff --git a/config/config_test.go b/config/config_test.go index d2a436538..0bfc2a1c4 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -84,6 +84,22 @@ func TestLoadBadConfigs(t *testing.T) { input: "testdata/invalid-http-request-compression-reject-all-encodings.yml", want: `error parsing config file: invalid configuration "Accept-Encoding: *;q=0.0", "compression: gzip"`, }, + { + input: "testdata/invalid-http-http3-http2.yml", + want: "error parsing config file: HTTP/3 and HTTP/2.0/1.1 cannot be used together - only HTTP/3.0 is allowed when enable_http3 is true", + }, + { + input: "testdata/invalid-no-versions-http3-enabled.yml", + want: "error parsing config file: when enable_http3 is true, enable_http2 must be set to false", + }, + { + input: "testdata/invalid-http-http3-http2-version.yml", + want: "error parsing config file: HTTP/3 and HTTP/2.0/1.1 cannot be used together - only HTTP/3.0 is allowed when enable_http3 is true", + }, + { + input: "testdata/invalid-http-http3-http2-enabled.yml", + want: "error parsing config file: when enable_http3 is true, enable_http2 must be set to false", + }, { input: "testdata/invalid-icmp-ttl.yml", want: "error parsing config file: \"ttl\" cannot be negative", diff --git a/config/testdata/invalid-http-http3-http2-enabled.yml b/config/testdata/invalid-http-http3-http2-enabled.yml new file mode 100644 index 000000000..1c8d9b686 --- /dev/null +++ b/config/testdata/invalid-http-http3-http2-enabled.yml @@ -0,0 +1,9 @@ +modules: + http_headers: + prober: http + timeout: 5s + http: + enable_http3: true + enable_http2: true + valid_http_versions: ["HTTP/3.0"] + preferred_ip_protocol: "ip4" diff --git a/config/testdata/invalid-http-http3-http2-version.yml b/config/testdata/invalid-http-http3-http2-version.yml new file mode 100644 index 000000000..cbdce3ecf --- /dev/null +++ b/config/testdata/invalid-http-http3-http2-version.yml @@ -0,0 +1,9 @@ +modules: + http_headers: + prober: http + timeout: 5s + http: + enable_http3: true + enable_http2: false + valid_http_versions: ["HTTP/1.1", "HTTP/2.0", "HTTP/3.0"] + preferred_ip_protocol: "ip4" diff --git a/config/testdata/invalid-http-http3-http2.yml b/config/testdata/invalid-http-http3-http2.yml new file mode 100644 index 000000000..9efbd86a0 --- /dev/null +++ b/config/testdata/invalid-http-http3-http2.yml @@ -0,0 +1,9 @@ +modules: + http_headers: + prober: http + timeout: 5s + http: + enable_http3: true + enable_http2: true + valid_http_versions: ["HTTP/1.1", "HTTP/2.0", "HTTP/3.0"] + preferred_ip_protocol: "ip4" diff --git a/config/testdata/invalid-no-versions-http3-enabled.yml b/config/testdata/invalid-no-versions-http3-enabled.yml new file mode 100644 index 000000000..62f7e5d3d --- /dev/null +++ b/config/testdata/invalid-no-versions-http3-enabled.yml @@ -0,0 +1,7 @@ +modules: + http_headers: + prober: http + timeout: 5s + http: + enable_http3: true + preferred_ip_protocol: "ip4" diff --git a/go.mod b/go.mod index 166bdda7c..a6fcea0e9 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,14 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require ( + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect + github.com/onsi/ginkgo/v2 v2.9.5 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + go.uber.org/mock v0.5.0 // indirect +) + require ( cel.dev/expr v0.23.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -30,6 +38,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/quic-go/quic-go v0.52.0 github.com/stoewer/go-strcase v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect golang.org/x/crypto v0.39.0 // indirect diff --git a/go.sum b/go.sum index 94af3cd3f..50ce6a08a 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,9 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -21,6 +24,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -28,8 +33,11 @@ github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY= github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -50,6 +58,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= +github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= +github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= @@ -62,6 +74,10 @@ github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHk github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.52.0 h1:/SlHrCRElyaU6MaEPKqKr9z83sBg2v4FLLvWM+Z47pA= +github.com/quic-go/quic-go v0.52.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= @@ -69,6 +85,7 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= @@ -87,6 +104,8 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= @@ -99,6 +118,7 @@ golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= @@ -119,5 +139,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/prober/http.go b/prober/http.go index 32da2f5b5..f5d546f6f 100644 --- a/prober/http.go +++ b/prober/http.go @@ -40,6 +40,8 @@ import ( "github.com/prometheus/client_golang/prometheus" pconfig "github.com/prometheus/common/config" "github.com/prometheus/common/version" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" "golang.org/x/net/publicsuffix" "github.com/prometheus/blackbox_exporter/config" @@ -205,7 +207,6 @@ func newTransport(rt, noServerName http.RoundTripper, logger *slog.Logger) *tran // RoundTrip switches to a new trace, then runs embedded RoundTripper. func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { t.logger.Info("Making HTTP request", "url", req.URL.String(), "host", req.Host) - trace := &roundTripTrace{} if req.URL.Scheme == "https" { trace.tls = true @@ -221,7 +222,12 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { // This is a redirect to something other than the initial host, // so TLS ServerName should not be set. t.logger.Info("Address does not match first address, not sending TLS ServerName", "first", t.firstHost, "address", req.URL.Host) - return t.NoServerNameTransport.RoundTrip(req) + // For HTTP/3, NoServerNameTransport might be nil as we don't create a serverless transport + if t.NoServerNameTransport != nil { + return t.NoServerNameTransport.RoundTrip(req) + } + // If NoServerNameTransport is nil, fall back to the normal Transport + t.logger.Info("No serverless transport available, using standard transport") } return t.Transport.RoundTrip(req) @@ -380,6 +386,12 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr target = "http://" + target } + // For HTTP/3, ensure HTTPS is used + if httpConfig.UseHTTP3 && strings.HasPrefix(target, "http://") { + target = strings.Replace(target, "http://", "https://", 1) + logger.Warn("Converting HTTP to HTTPS for HTTP/3 compatibility", "original_target", strings.Replace(target, "https://", "http://", 1), "converted_target", target) + } + targetURL, err := url.Parse(target) if err != nil { logger.Error("Could not parse target URL", "err", err) @@ -415,17 +427,50 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr } } } - client, err := pconfig.NewClientFromConfig(httpClientConfig, "http_probe", pconfig.WithKeepAlivesDisabled()) - if err != nil { - logger.Error("Error generating HTTP client", "err", err) - return false - } + var client *http.Client + var noServerName http.RoundTripper - httpClientConfig.TLSConfig.ServerName = "" - noServerName, err := pconfig.NewRoundTripperFromConfig(httpClientConfig, "http_probe", pconfig.WithKeepAlivesDisabled()) - if err != nil { - logger.Error("Error generating HTTP client without ServerName", "err", err) - return false + if httpConfig.UseHTTP3 { + // For HTTP/3, create TLS config from httpClientConfig but ensure TLS 1.3 + tlsConfig, err := pconfig.NewTLSConfig(&httpClientConfig.TLSConfig) + if err != nil { + logger.Error("Error creating TLS config for HTTP/3", "err", err) + return false + } + + // HTTP/3 requires TLS 1.3 minimum + if tlsConfig.MinVersion < tls.VersionTLS13 { + tlsConfig.MinVersion = tls.VersionTLS13 + } + + http3Transport := &http3.Transport{ + TLSClientConfig: tlsConfig, + QUICConfig: &quic.Config{}, + } + defer http3Transport.Close() + + client = &http.Client{ + Transport: http3Transport, + } + + } else { + // For standard HTTP/HTTPS, create client from config + client, err = pconfig.NewClientFromConfig(httpClientConfig, "http_probe", pconfig.WithKeepAlivesDisabled()) + if err != nil { + logger.Error("Error generating HTTP client", "err", err) + return false + } + + // Create a second transport without ServerName for redirects to different hosts + // See https://github.com/quic-go/quic-go/issues/4049 for why we don't do this for HTTP/3 + serverNamelessConfig := httpClientConfig + serverNamelessConfig.TLSConfig.ServerName = "" + + noServerName, err = pconfig.NewRoundTripperFromConfig(serverNamelessConfig, "http_probe", pconfig.WithKeepAlivesDisabled()) + if err != nil { + logger.Error("Error generating HTTP client without ServerName", "err", err) + return false + } } jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) @@ -438,6 +483,7 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr // Inject transport that tracks traces for each redirect, // and does not set TLS ServerNames on redirect if needed. tt := newTransport(client.Transport, noServerName, logger) + client.Transport = tt client.CheckRedirect = func(r *http.Request, via []*http.Request) error { @@ -455,8 +501,8 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr } origHost := targetURL.Host - if ip != nil { - // Replace the host field in the URL with the IP we resolved. + if ip != nil && !httpConfig.UseHTTP3 { + // Replace the host field in the URL with the IP we resolved if not using HTTP/3. if targetPort == "" { if strings.Contains(ip.String(), ":") { targetURL.Host = "[" + ip.String() + "]" @@ -493,6 +539,7 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr return } request.Host = origHost + request = request.WithContext(ctx) for key, value := range httpConfig.Headers { @@ -519,6 +566,7 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr TLSHandshakeStart: tt.TLSHandshakeStart, TLSHandshakeDone: tt.TLSHandshakeDone, } + request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace)) for _, lv := range []string{"connect", "tls", "processing", "transfer"} { diff --git a/prober/http_test.go b/prober/http_test.go index 3cb3ba663..59d289ce6 100644 --- a/prober/http_test.go +++ b/prober/http_test.go @@ -18,12 +18,15 @@ import ( "compress/flate" "compress/gzip" "context" + "crypto/rand" + "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/pem" "fmt" "io" "log/slog" + "math/big" "net" "net/http" "net/http/httptest" @@ -32,6 +35,7 @@ import ( "os" "strconv" "strings" + "sync" "testing" "time" @@ -39,6 +43,7 @@ import ( "github.com/prometheus/client_golang/prometheus" pconfig "github.com/prometheus/common/config" "github.com/prometheus/common/promslog" + "github.com/quic-go/quic-go/http3" "github.com/prometheus/blackbox_exporter/config" ) @@ -86,7 +91,9 @@ func TestValidHTTPVersion(t *testing.T) { {[]string{}, true}, {[]string{"HTTP/1.1"}, true}, {[]string{"HTTP/1.1", "HTTP/2.0"}, true}, + {[]string{"HTTP/1.1", "HTTP/2.0", "HTTP/3.0"}, false}, {[]string{"HTTP/2.0"}, false}, + {[]string{"HTTP/3.0"}, false}, } for i, test := range tests { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -100,7 +107,15 @@ func TestValidHTTPVersion(t *testing.T) { ValidHTTPVersions: test.ValidHTTPVersions, }}, registry, promslog.NewNopLogger()) body := recorder.Body.String() - if result != test.ShouldSucceed { + + if result { + for _, httpVersion := range test.ValidHTTPVersions { + if httpVersion == "HTTP/3.0" && test.ShouldSucceed { + t.Fatalf("[config.go] Did not pass config validation for ValidHTTPVersions: %v", test.ValidHTTPVersions) + } + } + } + if !result && test.ShouldSucceed { t.Fatalf("Test %v had unexpected result: %s", i, body) } } @@ -1806,3 +1821,159 @@ func TestBody(t *testing.T) { } } } + +func TestValidHTTPVersionsQUIC(t *testing.T) { + tests := []struct { + ValidHTTPVersions []string + ShouldSucceed bool + }{ + {[]string{"HTTP/1.1", "HTTP/2.0", "HTTP/3.0"}, false}, + {[]string{"HTTP/1.1", "HTTP/2.0"}, false}, + {[]string{"HTTP/3.0"}, true}, + {[]string{"HTTP/1.1"}, false}, + {[]string{"HTTP/2.0"}, false}, + {[]string{}, true}, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("test_%d_%v", i, test.ValidHTTPVersions), func(t *testing.T) { + s, serverURL := setupHTTP3Server(t) + defer s.Close() + + registry := prometheus.NewRegistry() + testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := ProbeHTTP(testCTX, serverURL, + config.Module{Timeout: 5 * time.Second, HTTP: config.HTTPProbe{ + IPProtocolFallback: true, + UseHTTP3: true, + ValidHTTPVersions: test.ValidHTTPVersions, + HTTPClientConfig: pconfig.HTTPClientConfig{ + TLSConfig: pconfig.TLSConfig{InsecureSkipVerify: true}, + }, + }}, registry, promslog.NewNopLogger()) + if result { + for _, httpVersion := range test.ValidHTTPVersions { + if (httpVersion == "HTTP/1.1" || httpVersion == "HTTP/2.0") && test.ShouldSucceed { + t.Fatalf("[config.go] Did not pass config validation for ValidHTTPVersions: %v", test.ValidHTTPVersions) + } + } + } + if !result && test.ShouldSucceed { + t.Fatalf("Test %d, ValidHTTPVersions: %v, Got result: %v, Want: %v", i, test.ValidHTTPVersions, result, test.ShouldSucceed) + } + }) + } +} + +func TestHTTP3ProbeQUIC(t *testing.T) { + s, serverURL := setupHTTP3Server(t) + defer s.Close() + + registry := prometheus.NewRegistry() + testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := ProbeHTTP(testCTX, serverURL, + config.Module{Timeout: 5 * time.Second, + HTTP: config.HTTPProbe{ + IPProtocolFallback: true, + UseHTTP3: true, + ValidHTTPVersions: []string{"HTTP/3.0"}, + HTTPClientConfig: pconfig.HTTPClientConfig{ + TLSConfig: pconfig.TLSConfig{InsecureSkipVerify: true}, + }, + }}, registry, promslog.NewNopLogger()) + + if !result { + t.Fatalf("HTTP/3 QUIC probe failed unexpectedly") + } + + mfs, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + + expectedResults := map[string]float64{ + "probe_http_status_code": 200, + "probe_http_version": 3, + } + checkRegistryResults(expectedResults, mfs, t) +} + +func generateTLSConfig() (*tls.Config, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + return &tls.Config{}, err + } + template := x509.Certificate{SerialNumber: big.NewInt(1)} + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return &tls.Config{}, err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return &tls.Config{}, err + } + return &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + NextProtos: []string{"h3"}, + }, nil +} + +func setupHTTP3Server(t *testing.T) (*http3.Server, string) { + tlsConfig, err := generateTLSConfig() + if err != nil { + t.Fatalf("failed to generate TLS config: %v", err) + } + + udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to resolve UDP addr: %v", err) + } + + udpConn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + t.Fatalf("failed to listen on UDP: %v", err) + } + port := udpConn.LocalAddr().(*net.UDPAddr).Port + udpConn.Close() + + addr := fmt.Sprintf("127.0.0.1:%d", port) + serverURL := fmt.Sprintf("https://%s", addr) + + server := &http3.Server{ + Addr: addr, + TLSConfig: tlsConfig, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }), + } + + serverStarted := make(chan error, 1) + var wg sync.WaitGroup + wg.Add(1) + go func() { + wg.Done() + if err := server.ListenAndServe(); err != nil { + if !strings.Contains(err.Error(), "server closed") { + serverStarted <- err + return + } + } + }() + + wg.Wait() + + select { + case err := <-serverStarted: + t.Fatalf("HTTP/3 server failed to start: %v", err) + default: + // Server started + } + return server, serverURL +}