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 }