github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/prober/http/http.go (about)

     1  package http
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"hash/fnv"
     8  	"net/http"
     9  	"net/url"
    10  	"strconv"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/grafana/synthetic-monitoring-agent/internal/model"
    15  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/interpolation"
    16  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/logger"
    17  	"github.com/grafana/synthetic-monitoring-agent/internal/secrets"
    18  	"github.com/grafana/synthetic-monitoring-agent/internal/tls"
    19  	"github.com/grafana/synthetic-monitoring-agent/internal/version"
    20  	sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring"
    21  	"github.com/prometheus/blackbox_exporter/config"
    22  	bbeprober "github.com/prometheus/blackbox_exporter/prober"
    23  	"github.com/prometheus/client_golang/prometheus"
    24  	promconfig "github.com/prometheus/common/config"
    25  	"github.com/rs/zerolog"
    26  )
    27  
    28  var errUnsupportedCheck = errors.New("unsupported check")
    29  
    30  type Prober struct {
    31  	// Raw settings and dependencies for runtime secret resolution
    32  	settings                   *sm.HttpSettings
    33  	timeout                    time.Duration
    34  	secretStore                secrets.SecretProvider
    35  	tenantID                   model.GlobalID
    36  	logger                     zerolog.Logger
    37  	cacheBustingQueryParamName string
    38  	// Static config that doesn't need secret resolution
    39  	staticConfig config.Module
    40  }
    41  
    42  func NewProber(ctx context.Context, check model.Check, logger zerolog.Logger, reservedHeaders http.Header, secretStore secrets.SecretProvider) (Prober, error) {
    43  	if check.Settings.Http == nil {
    44  		return Prober{}, errUnsupportedCheck
    45  	}
    46  
    47  	if len(reservedHeaders) > 0 {
    48  		augmentHttpHeaders(&check.Check, reservedHeaders)
    49  	}
    50  
    51  	// Build static configuration (everything except authentication secrets)
    52  	staticCfg, err := buildStaticConfig(check.Settings.Http)
    53  	if err != nil {
    54  		return Prober{}, err
    55  	}
    56  
    57  	staticCfg.Timeout = time.Duration(check.Timeout) * time.Millisecond
    58  
    59  	return Prober{
    60  		settings:                   check.Settings.Http,
    61  		timeout:                    time.Duration(check.Timeout) * time.Millisecond,
    62  		secretStore:                secretStore,
    63  		tenantID:                   check.GlobalTenantID(),
    64  		logger:                     logger.With().Str("prober", "http").Logger(),
    65  		cacheBustingQueryParamName: check.Settings.Http.CacheBustingQueryParamName,
    66  		staticConfig:               staticCfg,
    67  	}, nil
    68  }
    69  
    70  func (p Prober) Name() string {
    71  	return "http"
    72  }
    73  
    74  func (p Prober) Probe(ctx context.Context, target string, registry *prometheus.Registry, l logger.Logger, _ string) (bool, float64) {
    75  	slogger := logger.ToSlog(l)
    76  
    77  	if p.cacheBustingQueryParamName != "" {
    78  		// FIXME(mem): the second target argument should be the probe's name
    79  		target = addCacheBustParam(target, p.cacheBustingQueryParamName, target)
    80  	}
    81  
    82  	// Resolve secrets and build complete config at probe time
    83  	probeConfig, err := p.buildProbeConfig(ctx)
    84  	if err != nil {
    85  		p.logger.Error().Err(err).Msg("failed to resolve secrets for HTTP probe")
    86  		return false, 0
    87  	}
    88  
    89  	return bbeprober.ProbeHTTP(ctx, target, probeConfig, registry, slogger), 0
    90  }
    91  
    92  // buildProbeConfig creates the complete configuration with resolved secrets
    93  func (p Prober) buildProbeConfig(ctx context.Context) (config.Module, error) {
    94  	// Start with static config
    95  	cfg := p.staticConfig
    96  
    97  	// Resolve authentication secrets at probe time
    98  	httpClientConfig, err := buildPrometheusHTTPClientConfig(
    99  		ctx,
   100  		p.settings,
   101  		p.logger,
   102  		p.secretStore,
   103  		p.tenantID,
   104  	)
   105  	if err != nil {
   106  		return cfg, fmt.Errorf("failed to build HTTP client config: %w", err)
   107  	}
   108  
   109  	cfg.HTTP.HTTPClientConfig = httpClientConfig
   110  
   111  	// Set BBE's SkipResolvePhaseWithProxy when a proxy is configured
   112  	if cfg.HTTP.HTTPClientConfig.ProxyURL.URL != nil {
   113  		cfg.HTTP.SkipResolvePhaseWithProxy = true
   114  	}
   115  
   116  	// Handle OAuth2 config if present
   117  	if p.settings.Oauth2Config != nil && p.settings.Oauth2Config.ClientId != "" {
   118  		oauth2Config, err := convertOAuth2Config(ctx, p.settings.Oauth2Config, p.logger)
   119  		if err != nil {
   120  			return cfg, fmt.Errorf("parsing OAuth2 settings: %w", err)
   121  		}
   122  
   123  		cfg.HTTP.HTTPClientConfig.OAuth2 = oauth2Config
   124  	}
   125  
   126  	return cfg, nil
   127  }
   128  
   129  // buildStaticConfig creates the parts of the config that don't require secret resolution
   130  func buildStaticConfig(settings *sm.HttpSettings) (config.Module, error) {
   131  	var m config.Module
   132  
   133  	m.Prober = sm.CheckTypeHttp.String()
   134  
   135  	m.HTTP.IPProtocol, m.HTTP.IPProtocolFallback = settings.IpVersion.ToIpProtocol()
   136  
   137  	m.HTTP.Body = settings.Body
   138  
   139  	m.HTTP.Method = settings.Method.String()
   140  
   141  	m.HTTP.FailIfSSL = settings.FailIfSSL
   142  
   143  	m.HTTP.FailIfNotSSL = settings.FailIfNotSSL
   144  
   145  	m.HTTP.Headers = buildHttpHeaders(settings.Headers)
   146  
   147  	if settings.Compression != sm.CompressionAlgorithm_none {
   148  		m.HTTP.Compression = settings.Compression.String()
   149  	}
   150  
   151  	m.HTTP.ValidStatusCodes = make([]int, 0, len(settings.ValidStatusCodes))
   152  	for _, code := range settings.ValidStatusCodes {
   153  		m.HTTP.ValidStatusCodes = append(m.HTTP.ValidStatusCodes, int(code))
   154  	}
   155  
   156  	m.HTTP.ValidHTTPVersions = make([]string, len(settings.ValidHTTPVersions))
   157  	copy(m.HTTP.ValidHTTPVersions, settings.ValidHTTPVersions)
   158  
   159  	m.HTTP.FailIfBodyMatchesRegexp = make([]config.Regexp, 0, len(settings.FailIfBodyMatchesRegexp))
   160  	for _, str := range settings.FailIfBodyMatchesRegexp {
   161  		re, err := config.NewRegexp(str)
   162  		if err != nil {
   163  			return m, err
   164  		}
   165  
   166  		m.HTTP.FailIfBodyMatchesRegexp = append(m.HTTP.FailIfBodyMatchesRegexp, re)
   167  	}
   168  
   169  	m.HTTP.FailIfBodyNotMatchesRegexp = make([]config.Regexp, 0, len(settings.FailIfBodyNotMatchesRegexp))
   170  	for _, str := range settings.FailIfBodyNotMatchesRegexp {
   171  		re, err := config.NewRegexp(str)
   172  		if err != nil {
   173  			return m, err
   174  		}
   175  
   176  		m.HTTP.FailIfBodyNotMatchesRegexp = append(m.HTTP.FailIfBodyNotMatchesRegexp, re)
   177  	}
   178  
   179  	m.HTTP.FailIfHeaderMatchesRegexp = make([]config.HeaderMatch, 0, len(settings.FailIfHeaderMatchesRegexp))
   180  	for _, match := range settings.FailIfHeaderMatchesRegexp {
   181  		re, err := config.NewRegexp(match.Regexp)
   182  		if err != nil {
   183  			return m, err
   184  		}
   185  
   186  		m.HTTP.FailIfHeaderMatchesRegexp = append(m.HTTP.FailIfHeaderMatchesRegexp, config.HeaderMatch{
   187  			Header:       match.Header,
   188  			Regexp:       re,
   189  			AllowMissing: match.AllowMissing,
   190  		})
   191  	}
   192  
   193  	m.HTTP.FailIfHeaderNotMatchesRegexp = make([]config.HeaderMatch, 0, len(settings.FailIfHeaderNotMatchesRegexp))
   194  	for _, match := range settings.FailIfHeaderNotMatchesRegexp {
   195  		re, err := config.NewRegexp(match.Regexp)
   196  		if err != nil {
   197  			return m, err
   198  		}
   199  
   200  		m.HTTP.FailIfHeaderNotMatchesRegexp = append(m.HTTP.FailIfHeaderNotMatchesRegexp, config.HeaderMatch{
   201  			Header:       match.Header,
   202  			Regexp:       re,
   203  			AllowMissing: match.AllowMissing,
   204  		})
   205  	}
   206  
   207  	return m, nil
   208  }
   209  
   210  // resolveSecretValue resolves a secret value using string interpolation with ${secrets.secret_name} syntax.
   211  // If secretManagerEnabled is false, the value is returned as-is without any interpolation.
   212  func resolveSecretValue(ctx context.Context, value string, secretStore secrets.SecretProvider, tenantID model.GlobalID, logger zerolog.Logger, secretManagerEnabled bool) (string, error) {
   213  	if value == "" {
   214  		return "", nil
   215  	}
   216  
   217  	// If secret manager is not enabled, return the value as-is
   218  	if !secretManagerEnabled {
   219  		return value, nil
   220  	}
   221  
   222  	// Create a resolver that only handles secrets (no variables)
   223  	resolver := interpolation.NewResolver(nil, secretStore, tenantID, logger, secretManagerEnabled)
   224  
   225  	return resolver.Resolve(ctx, value)
   226  }
   227  
   228  func buildPrometheusHTTPClientConfig(ctx context.Context, settings *sm.HttpSettings, logger zerolog.Logger, secretStore secrets.SecretProvider, tenantID model.GlobalID) (promconfig.HTTPClientConfig, error) {
   229  	var cfg promconfig.HTTPClientConfig
   230  
   231  	// Enable HTTP2 for all checks.
   232  	cfg.EnableHTTP2 = true
   233  
   234  	// We could do something like this instead:
   235  	//
   236  	// for _, v := range m.HTTP.ValidHTTPVersions {
   237  	// 	if strings.HasPrefix(v, "HTTP/2") {
   238  	// 		cfg.EnableHTTP2 = true
   239  	// 		break
   240  	// 	}
   241  	// }
   242  	//
   243  	// but this needs to be evaluated. Go changed the behaviour so
   244  	// that HTTP2 is enabled, and blacbox exporter follows that in
   245  	// v0.21.0 (this setting defaults to true). We could add a
   246  	// setting to _disable_ HTTP2. Eventually we are going to
   247  	// introduce support for HTTP3, so that setting should be
   248  	// something closer to what Go itself does which is specify a
   249  	// supported / wanted protocol.
   250  
   251  	cfg.FollowRedirects = !settings.NoFollowRedirects
   252  
   253  	if settings.TlsConfig != nil {
   254  		var err error
   255  
   256  		cfg.TLSConfig, err = buildTLSConfig(ctx, settings.TlsConfig, secretStore, tenantID, logger, settings.SecretManagerEnabled)
   257  		if err != nil {
   258  			return cfg, err
   259  		}
   260  	}
   261  
   262  	// Resolve bearer token (may be a secret)
   263  	bearerToken, err := resolveSecretValue(ctx, settings.BearerToken, secretStore, tenantID, logger, settings.SecretManagerEnabled)
   264  	if err != nil {
   265  		return cfg, fmt.Errorf("failed to resolve bearer token: %w", err)
   266  	}
   267  
   268  	cfg.BearerToken = promconfig.Secret(bearerToken)
   269  
   270  	if settings.BasicAuth != nil {
   271  		// Resolve password (may be a secret)
   272  		password, err := resolveSecretValue(ctx, settings.BasicAuth.Password, secretStore, tenantID, logger, settings.SecretManagerEnabled)
   273  		if err != nil {
   274  			return cfg, fmt.Errorf("failed to resolve basic auth password: %w", err)
   275  		}
   276  
   277  		cfg.BasicAuth = &promconfig.BasicAuth{
   278  			Username: settings.BasicAuth.Username,
   279  			Password: promconfig.Secret(password),
   280  		}
   281  	}
   282  
   283  	if settings.ProxyURL != "" {
   284  		var err error
   285  
   286  		cfg.ProxyURL.URL, err = url.Parse(settings.ProxyURL)
   287  		if err != nil {
   288  			return cfg, fmt.Errorf("parsing proxy URL: %w", err)
   289  		}
   290  
   291  		if len(settings.ProxyConnectHeaders) > 0 {
   292  			headers := make(promconfig.ProxyHeader)
   293  
   294  			for _, h := range settings.ProxyConnectHeaders {
   295  				name, value := strToHeaderNameValue(h)
   296  				headers[name] = []promconfig.Secret{promconfig.Secret(value)}
   297  			}
   298  
   299  			cfg.ProxyConnectHeader = headers
   300  		}
   301  	}
   302  
   303  	return cfg, nil
   304  }
   305  
   306  // buildTLSConfig builds a Prometheus TLS config from SM TLS config with secret resolution support
   307  func buildTLSConfig(ctx context.Context, tlsConfig *sm.TLSConfig, secretStore secrets.SecretProvider, tenantID model.GlobalID, logger zerolog.Logger, secretManagerEnabled bool) (promconfig.TLSConfig, error) {
   308  	// Create a copy of the TLS config with resolved secrets
   309  	resolvedTLSConfig := &sm.TLSConfig{
   310  		InsecureSkipVerify: tlsConfig.InsecureSkipVerify,
   311  		ServerName:         tlsConfig.ServerName,
   312  	}
   313  
   314  	// Resolve CA cert if present
   315  	if len(tlsConfig.CACert) > 0 {
   316  		if secretManagerEnabled {
   317  			// Resolve CA cert from secret if secret manager is enabled
   318  			caCertStr, err := resolveSecretValue(ctx, string(tlsConfig.CACert), secretStore, tenantID, logger, secretManagerEnabled)
   319  			if err != nil {
   320  				return promconfig.TLSConfig{}, fmt.Errorf("failed to resolve CA cert: %w", err)
   321  			}
   322  
   323  			resolvedTLSConfig.CACert = []byte(caCertStr)
   324  		} else {
   325  			resolvedTLSConfig.CACert = tlsConfig.CACert
   326  		}
   327  	}
   328  
   329  	// Resolve client cert if present
   330  	if len(tlsConfig.ClientCert) > 0 {
   331  		if secretManagerEnabled {
   332  			// Resolve client cert from secret if secret manager is enabled
   333  			clientCertStr, err := resolveSecretValue(ctx, string(tlsConfig.ClientCert), secretStore, tenantID, logger, secretManagerEnabled)
   334  			if err != nil {
   335  				return promconfig.TLSConfig{}, fmt.Errorf("failed to resolve client cert: %w", err)
   336  			}
   337  
   338  			resolvedTLSConfig.ClientCert = []byte(clientCertStr)
   339  		} else {
   340  			resolvedTLSConfig.ClientCert = tlsConfig.ClientCert
   341  		}
   342  	}
   343  
   344  	// Resolve client key if present
   345  	if len(tlsConfig.ClientKey) > 0 {
   346  		if secretManagerEnabled {
   347  			// Resolve client key from secret if secret manager is enabled
   348  			clientKeyStr, err := resolveSecretValue(ctx, string(tlsConfig.ClientKey), secretStore, tenantID, logger, secretManagerEnabled)
   349  			if err != nil {
   350  				return promconfig.TLSConfig{}, fmt.Errorf("failed to resolve client key: %w", err)
   351  			}
   352  
   353  			resolvedTLSConfig.ClientKey = []byte(clientKeyStr)
   354  		} else {
   355  			resolvedTLSConfig.ClientKey = tlsConfig.ClientKey
   356  		}
   357  	}
   358  
   359  	// Use the existing TLS conversion function with resolved config
   360  	return tls.SMtoProm(ctx, logger, resolvedTLSConfig)
   361  }
   362  
   363  func convertOAuth2Config(ctx context.Context, cfg *sm.OAuth2Config, logger zerolog.Logger) (*promconfig.OAuth2, error) {
   364  	r := &promconfig.OAuth2{}
   365  	r.ClientID = cfg.ClientId
   366  	r.ClientSecret = promconfig.Secret(cfg.ClientSecret)
   367  	r.TokenURL = cfg.TokenURL
   368  	r.Scopes = make([]string, len(cfg.Scopes))
   369  	copy(r.Scopes, cfg.Scopes)
   370  
   371  	r.EndpointParams = make(map[string]string, len(cfg.EndpointParams))
   372  	for _, pair := range cfg.EndpointParams {
   373  		r.EndpointParams[pair.Name] = pair.Value
   374  	}
   375  
   376  	var err error
   377  	if cfg.ProxyURL != "" {
   378  		r.ProxyURL.URL, err = url.Parse(cfg.ProxyURL)
   379  		if err != nil {
   380  			return nil, fmt.Errorf("parsing proxy URL: %w", err)
   381  		}
   382  	}
   383  
   384  	if cfg.TlsConfig != nil {
   385  		r.TLSConfig, err = tls.SMtoProm(ctx, logger, cfg.TlsConfig)
   386  		if err != nil {
   387  			return nil, fmt.Errorf("parsing TLS config: %w", err)
   388  		}
   389  	}
   390  
   391  	return r, nil
   392  }
   393  
   394  // Overrides any user-provided headers with our own augmented values
   395  // for reserved headers.
   396  func augmentHttpHeaders(check *sm.Check, reservedHeaders http.Header) {
   397  	headers := []string{}
   398  
   399  	for _, header := range check.Settings.Http.Headers {
   400  		name, _ := strToHeaderNameValue(header)
   401  
   402  		_, present := reservedHeaders[http.CanonicalHeaderKey(name)]
   403  		if present {
   404  			continue // users can't override reserved headers with their own values
   405  		}
   406  
   407  		headers = append(headers, header)
   408  	}
   409  
   410  	for key, values := range reservedHeaders {
   411  		var b strings.Builder
   412  		for _, value := range values {
   413  			b.Reset()
   414  			b.WriteString(key)
   415  			b.WriteRune(':')
   416  			b.WriteString(value)
   417  			headers = append(headers, b.String())
   418  		}
   419  	}
   420  
   421  	check.Settings.Http.Headers = headers
   422  }
   423  
   424  func buildHttpHeaders(headers []string) map[string]string {
   425  	userAgentHeader := "user-agent"
   426  
   427  	h := map[string]string{
   428  		userAgentHeader: version.UserAgent(), // default user-agent header
   429  	}
   430  
   431  	for _, header := range headers {
   432  		name, value := strToHeaderNameValue(header)
   433  
   434  		if strings.ToLower(name) == userAgentHeader {
   435  			// Remove the default user-agent header and
   436  			// replace it with the one the user is
   437  			// specifying, so that we respect whatever case
   438  			// they chose (e.g. "user-agent" vs
   439  			// "User-Agent").
   440  			delete(h, userAgentHeader)
   441  		}
   442  
   443  		h[name] = value
   444  	}
   445  
   446  	return h
   447  }
   448  
   449  func strToHeaderNameValue(s string) (string, string) {
   450  	parts := strings.SplitN(s, ":", 2)
   451  
   452  	var value string
   453  	if len(parts) == 2 {
   454  		value = strings.TrimLeft(parts[1], " ")
   455  	}
   456  
   457  	return parts[0], value
   458  }
   459  
   460  func addCacheBustParam(target, paramName, salt string) string {
   461  	// we already know this URL is valid
   462  	u, _ := url.Parse(target)
   463  	q := u.Query()
   464  	value := hashString(salt, strconv.FormatInt(time.Now().UnixNano(), 10))
   465  	q.Set(paramName, value)
   466  	u.RawQuery = q.Encode()
   467  
   468  	return u.String()
   469  }
   470  
   471  func hashString(salt, str string) string {
   472  	h := fnv.New64a()
   473  	_, _ = h.Write([]byte(salt))
   474  	_, _ = h.Write([]byte(str))
   475  
   476  	return strconv.FormatUint(h.Sum64(), 16)
   477  }