Skip to content

Commit f30d496

Browse files
committed
Introduced HTTPClientConfig to consolidate OAuth2 and other common configurations.
Mimics upstream configuration so that json config remains similar. - Added proxy settings, including `ProxyURL`, `NoProxy`, and `ProxyConnectHeader`, and ensured validation. - Updated OAuth2 configuration to support proxy usage. - Added tests for HTTP client, proxy configuration, and OAuth2 validation.
1 parent 5606fc0 commit f30d496

File tree

10 files changed

+467
-115
lines changed

10 files changed

+467
-115
lines changed

http/client.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import (
2323
var ErrInvalidMethod = errors.New("webhook only supports HTTP methods PUT or POST")
2424

2525
type clientConfiguration struct {
26-
userAgent string
27-
dialer net.Dialer // We use Dialer here instead of DialContext as our mqtt client doesn't support DialContext.
28-
ouath2Config *OAuth2Config
26+
userAgent string
27+
dialer net.Dialer // We use Dialer here instead of DialContext as our mqtt client doesn't support DialContext.
28+
httpClientConfig HTTPClientConfig
2929
}
3030

3131
// defaultDialTimeout is the default timeout for the dialer, 30 seconds to match http.DefaultTransport.
@@ -55,8 +55,8 @@ func NewClient(opts ...ClientOption) (*Client, error) {
5555
cfg: cfg,
5656
}
5757

58-
if cfg.ouath2Config != nil {
59-
if err := ValidateOAuth2Config(*cfg.ouath2Config); err != nil {
58+
if cfg.httpClientConfig.OAuth2 != nil {
59+
if err := ValidateOAuth2Config(cfg.httpClientConfig.OAuth2); err != nil {
6060
return nil, fmt.Errorf("invalid OAuth2 configuration: %w", err)
6161
}
6262
// If the user has provided an OAuth2 config, we need to prepare the OAuth2 token source. This needs to
@@ -85,9 +85,11 @@ func WithDialer(dialer net.Dialer) ClientOption {
8585
}
8686
}
8787

88-
func WithOAuth2(config *OAuth2Config) ClientOption {
88+
func WithHTTPClientConfig(config *HTTPClientConfig) ClientOption {
8989
return func(c *clientConfiguration) {
90-
c.ouath2Config = config
90+
if config != nil {
91+
c.httpClientConfig = *config
92+
}
9193
}
9294
}
9395

http/client_test.go

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,12 @@ func TestClient(t *testing.T) {
5757
ClientSecret: "test-client-secret",
5858
TokenURL: "https://localhost:8080/oauth2/token",
5959
}
60-
client, err := NewClient(WithOAuth2(oauth2Config))
60+
client, err := NewClient(WithHTTPClientConfig(&HTTPClientConfig{
61+
OAuth2: oauth2Config,
62+
}))
6163
require.NoError(t, err)
6264

63-
require.Equal(t, oauth2Config, client.cfg.ouath2Config)
65+
require.Equal(t, oauth2Config, client.cfg.httpClientConfig.OAuth2)
6466
})
6567

6668
t.Run("WithOAuth2 invalid TLS", func(t *testing.T) {
@@ -72,7 +74,9 @@ func TestClient(t *testing.T) {
7274
CACertificate: "invalid-ca-cert",
7375
},
7476
}
75-
_, err := NewClient(WithOAuth2(oauth2Config))
77+
_, err := NewClient(WithHTTPClientConfig(&HTTPClientConfig{
78+
OAuth2: oauth2Config,
79+
}))
7680
require.ErrorIs(t, err, ErrOAuth2TLSConfigInvalid)
7781
})
7882
}
@@ -245,6 +249,7 @@ func TestSendWebhookOAuth2(t *testing.T) {
245249
expOAuth2RequestValues url.Values
246250
expClientError error
247251
expOAuthError error
252+
expProxyRequests bool
248253
}{
249254
{
250255
name: "valid simple OAuth2 config",
@@ -408,11 +413,33 @@ func TestSendWebhookOAuth2(t *testing.T) {
408413
},
409414
expOAuthError: customDialError,
410415
},
416+
{
417+
name: "proxy in OAuth2 config",
418+
oauth2Config: OAuth2Config{
419+
ClientID: "test-client-id",
420+
ClientSecret: "test-client-secret",
421+
ProxyConfig: &ProxyConfig{
422+
ProxyURL: "xxxx", // This will be replaced with the test server URL.
423+
},
424+
},
425+
oauth2Response: oauth2Response{
426+
AccessToken: "12345",
427+
TokenType: "Bearer",
428+
},
429+
430+
expOAuth2RequestValues: url.Values{
431+
"grant_type": []string{"client_credentials"},
432+
},
433+
expOAuth2AuthHeaders: http.Header{
434+
"Authorization": []string{GetBasicAuthHeader("test-client-id", "test-client-secret")},
435+
},
436+
expProxyRequests: true,
437+
},
411438
}
412439
for _, tc := range tcs {
413440
t.Run(tc.name, func(t *testing.T) {
414441
oathRequestCnt := 0
415-
oauth2Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
442+
oauthHandler := func(w http.ResponseWriter, r *http.Request) {
416443
oathRequestCnt++
417444

418445
for k := range tc.expOAuth2AuthHeaders {
@@ -427,8 +454,22 @@ func TestSendWebhookOAuth2(t *testing.T) {
427454
res, _ := json.Marshal(tc.oauth2Response)
428455
w.Header().Add("Content-Type", "application/json")
429456
_, _ = w.Write(res)
430-
}))
457+
}
458+
459+
oauth2Server := httptest.NewServer(http.HandlerFunc(oauthHandler))
431460
defer oauth2Server.Close()
461+
tokenUrl := oauth2Server.URL + "/oauth2/token"
462+
463+
proxyRequestCnt := 0
464+
proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
465+
proxyRequestCnt++
466+
// Verify this is a proxy request.
467+
assert.Equal(t, tokenUrl, r.RequestURI, "expected request to be sent to oauth server")
468+
469+
// Simulate forwarding the request to the OAuth2 handler.
470+
oauthHandler(w, r)
471+
}))
472+
defer proxyServer.Close()
432473

433474
webhookRequestCnt := 0
434475
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -440,8 +481,19 @@ func TestSendWebhookOAuth2(t *testing.T) {
440481
defer webhookServer.Close()
441482

442483
oauthConfig := tc.oauth2Config
443-
oauthConfig.TokenURL = oauth2Server.URL
444-
client, err := NewClient(append(tc.otherClientOpts, WithOAuth2(&oauthConfig))...)
484+
oauthConfig.TokenURL = tokenUrl
485+
486+
if oauthConfig.ProxyConfig != nil && oauthConfig.ProxyConfig.ProxyURL != "" {
487+
oauthConfig.ProxyConfig.ProxyURL = proxyServer.URL
488+
}
489+
expectedProxyRequestCnt := 0
490+
if tc.expProxyRequests {
491+
expectedProxyRequestCnt = 1
492+
}
493+
494+
client, err := NewClient(append(tc.otherClientOpts, WithHTTPClientConfig(&HTTPClientConfig{
495+
OAuth2: &oauthConfig,
496+
}))...)
445497
if tc.expClientError != nil {
446498
assert.ErrorIs(t, err, tc.expClientError, "expected client creation error to match")
447499
return
@@ -454,11 +506,13 @@ func TestSendWebhookOAuth2(t *testing.T) {
454506
HTTPMethod: http.MethodPost,
455507
})
456508
if tc.expOAuthError != nil {
457-
assert.Equal(t, 0, oathRequestCnt, "expected %d OAuth2 request to be sent, got: %d", 1, oathRequestCnt)
458-
assert.Equal(t, 0, webhookRequestCnt, "expected %d webhook request to be sent, got: %d", 1, webhookRequestCnt)
509+
assert.Equal(t, 0, proxyRequestCnt, "expected %d Proxy request to be sent, got: %d", 0, oathRequestCnt)
510+
assert.Equal(t, 0, oathRequestCnt, "expected %d OAuth2 request to be sent, got: %d", 0, oathRequestCnt)
511+
assert.Equal(t, 0, webhookRequestCnt, "expected %d webhook request to be sent, got: %d", 0, webhookRequestCnt)
459512
assert.ErrorIs(t, err, tc.expOAuthError, "expected error to match")
460513
return
461514
}
515+
assert.Equal(t, expectedProxyRequestCnt, proxyRequestCnt, "expected %d proxy request to be sent, got: %d", expectedProxyRequestCnt, proxyRequestCnt)
462516
assert.Equal(t, 1, oathRequestCnt, "expected %d OAuth2 request to be sent, got: %d", 1, oathRequestCnt)
463517
assert.Equal(t, 1, webhookRequestCnt, "expected %d webhook request to be sent, got: %d", 1, webhookRequestCnt)
464518
assert.NoError(t, err, "expected no error")
@@ -469,6 +523,7 @@ func TestSendWebhookOAuth2(t *testing.T) {
469523
Body: "test-body",
470524
HTTPMethod: http.MethodPost,
471525
})
526+
assert.Equal(t, expectedProxyRequestCnt, proxyRequestCnt, "expected %d proxy request to be sent, got: %d", expectedProxyRequestCnt, proxyRequestCnt)
472527
assert.Equal(t, 1, oathRequestCnt, "expected %d OAuth2 request to be sent, got: %d", 1, oathRequestCnt)
473528
assert.Equal(t, 2, webhookRequestCnt, "expected %d webhook request to be sent, got: %d", 2, webhookRequestCnt)
474529
})

http/config.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package http
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
9+
"golang.org/x/net/http/httpproxy"
10+
11+
"github.com/grafana/alerting/receivers"
12+
)
13+
14+
var (
15+
// ErrInvalidOAuth2Config is returned when the OAuth2 configuration is invalid.
16+
ErrInvalidOAuth2Config = fmt.Errorf("invalid OAuth2 configuration")
17+
// ErrInvalidProxyConfig is returned when the proxy configuration is invalid.
18+
ErrInvalidProxyConfig = fmt.Errorf("invalid proxy configuration")
19+
)
20+
21+
// HTTPClientConfig holds common configuration for notifier HTTP clients.
22+
type HTTPClientConfig struct {
23+
// The OAuth2 client credentials used to fetch a token for the targets.
24+
OAuth2 *OAuth2Config `yaml:"oauth2,omitempty" json:"oauth2,omitempty"`
25+
}
26+
27+
// Decrypt decrypts sensitive fields in the HTTPClientConfig.
28+
func (c *HTTPClientConfig) Decrypt(decryptFn receivers.DecryptFunc) {
29+
if c.OAuth2 != nil {
30+
c.OAuth2.ClientSecret = decryptFn("http_config.oauth2.client_secret", c.OAuth2.ClientSecret)
31+
if c.OAuth2.TLSConfig != nil {
32+
c.OAuth2.TLSConfig.CACertificate = decryptFn("http_config.oauth2.tls_config.caCertificate", c.OAuth2.TLSConfig.CACertificate)
33+
c.OAuth2.TLSConfig.ClientCertificate = decryptFn("http_config.oauth2.tls_config.clientCertificate", c.OAuth2.TLSConfig.ClientCertificate)
34+
c.OAuth2.TLSConfig.ClientKey = decryptFn("http_config.oauth2.tls_config.clientKey", c.OAuth2.TLSConfig.ClientKey)
35+
}
36+
}
37+
}
38+
39+
func ValidateHTTPClientConfig(cfg *HTTPClientConfig) error {
40+
if cfg != nil {
41+
if err := ValidateOAuth2Config(cfg.OAuth2); err != nil {
42+
return fmt.Errorf("%w: %w", ErrInvalidOAuth2Config, err)
43+
}
44+
}
45+
return nil
46+
}
47+
48+
type ProxyConfig struct {
49+
// ProxyURL is the HTTP proxy server to use to connect to the targets.
50+
ProxyURL string `yaml:"proxy_url,omitempty" json:"proxy_url,omitempty"`
51+
// NoProxy contains addresses that should not use a proxy.
52+
NoProxy string `yaml:"no_proxy,omitempty" json:"no_proxy,omitempty"`
53+
// ProxyFromEnvironment uses environment HTTP_PROXY, HTTPS_PROXY and NO_PROXY to determine proxies.
54+
ProxyFromEnvironment bool `yaml:"proxy_from_environment,omitempty" json:"proxy_from_environment,omitempty"`
55+
// ProxyConnectHeader optionally specifies headers to send to proxies during CONNECT requests.
56+
ProxyConnectHeader map[string]string `yaml:"proxy_connect_header,omitempty" json:"proxy_connect_header,omitempty"`
57+
}
58+
59+
// Proxy returns the Proxy URL for a request.
60+
func (cfg *ProxyConfig) Proxy() (fn func(*http.Request) (*url.URL, error), err error) {
61+
if cfg == nil {
62+
return nil, nil
63+
}
64+
if cfg.ProxyFromEnvironment {
65+
proxyFn := httpproxy.FromEnvironment().ProxyFunc()
66+
return func(req *http.Request) (*url.URL, error) {
67+
return proxyFn(req.URL)
68+
}, nil
69+
}
70+
if cfg.ProxyURL != "" {
71+
proxyUrl, err := url.Parse(cfg.ProxyURL)
72+
if err != nil {
73+
return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.ProxyURL, err)
74+
}
75+
if cfg.NoProxy == "" {
76+
return http.ProxyURL(proxyUrl), nil
77+
}
78+
proxy := &httpproxy.Config{
79+
HTTPProxy: proxyUrl.String(),
80+
HTTPSProxy: proxyUrl.String(),
81+
NoProxy: cfg.NoProxy,
82+
}
83+
proxyFn := proxy.ProxyFunc()
84+
return func(req *http.Request) (*url.URL, error) {
85+
return proxyFn(req.URL)
86+
}, nil
87+
}
88+
return nil, nil
89+
}
90+
91+
func (cfg *ProxyConfig) GetProxyConnectHeader() http.Header {
92+
if cfg == nil || len(cfg.ProxyConnectHeader) == 0 {
93+
return nil
94+
}
95+
// Return a copy of the header to avoid modifying the original.
96+
headerCopy := make(http.Header, len(cfg.ProxyConnectHeader))
97+
for k, v := range cfg.ProxyConnectHeader {
98+
headerCopy.Add(k, v)
99+
}
100+
return headerCopy
101+
}
102+
103+
func ValidateProxyConfig(cfg *ProxyConfig) error {
104+
if cfg == nil {
105+
// If no proxy config is provided, we consider it valid.
106+
return nil
107+
}
108+
if len(cfg.ProxyConnectHeader) > 0 && !cfg.ProxyFromEnvironment && cfg.ProxyURL == "" {
109+
return errors.New("if proxy_connect_header is configured, proxy_url or proxy_from_environment must also be configured")
110+
}
111+
if cfg.ProxyFromEnvironment && cfg.ProxyURL != "" {
112+
return errors.New("if proxy_from_environment is configured, proxy_url must not be configured")
113+
}
114+
if cfg.ProxyFromEnvironment && cfg.NoProxy != "" {
115+
return errors.New("if proxy_from_environment is configured, no_proxy must not be configured")
116+
}
117+
if cfg.ProxyURL == "" && cfg.NoProxy != "" {
118+
return errors.New("if no_proxy is configured, proxy_url must also be configured")
119+
}
120+
121+
if cfg.ProxyURL != "" {
122+
if _, err := url.Parse(cfg.ProxyURL); err != nil {
123+
return fmt.Errorf("invalid proxy URL %q: %w", cfg.ProxyURL, err)
124+
}
125+
}
126+
127+
return nil
128+
}

0 commit comments

Comments
 (0)