diff --git a/docs/pages/references/configuration.mdx b/docs/pages/references/configuration.mdx index ba54360d..ec027577 100644 --- a/docs/pages/references/configuration.mdx +++ b/docs/pages/references/configuration.mdx @@ -51,6 +51,7 @@ Global configurations are provided through env variables or a YAML file. ConfigM | `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. | `false` | No | | `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. | `false` | No | | `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-'). | `x-outpost-` | No | +| `DESTINATIONS_WEBHOOK_PROXY_URL` | Proxy URL for routing webhook requests through a proxy server. Supports HTTP and HTTPS proxies. When configured, all outgoing webhook traffic will be routed through the specified proxy. | `nil` | No | | `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM` | Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). | `hmac-sha256` | No | | `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | Go template for constructing the content to be signed for webhook requests. | `{{.Timestamp.Unix}}.{{.Body}}` | No | | `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | Encoding for the signature (e.g., 'hex', 'base64'). | `hex` | No | @@ -196,6 +197,9 @@ destinations: # Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-'). header_prefix: "x-outpost-" + # Proxy URL for routing webhook requests through a proxy server. Supports HTTP and HTTPS proxies. When configured, all outgoing webhook traffic will be routed through the specified proxy. + proxy_url: "" + # Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). signature_algorithm: "hmac-sha256" diff --git a/internal/app/app.go b/internal/app/app.go index a2d0ecf5..b65047dd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -198,10 +198,10 @@ func constructServices( // MIGRATION LOCK BEHAVIOR: // - Database locks are only acquired when migrations need to be performed // - When multiple nodes start simultaneously and migrations are pending: -// 1. One node acquires the lock and performs migrations (ideally < 5 seconds) -// 2. Other nodes fail with lock errors ("try lock failed", "can't acquire lock") -// 3. Failed nodes wait 5 seconds and retry -// 4. On retry, migrations are complete and nodes proceed successfully +// 1. One node acquires the lock and performs migrations (ideally < 5 seconds) +// 2. Other nodes fail with lock errors ("try lock failed", "can't acquire lock") +// 3. Failed nodes wait 5 seconds and retry +// 4. On retry, migrations are complete and nodes proceed successfully // // RETRY STRATEGY: // - Max 3 attempts with 5-second delays between retries diff --git a/internal/config/destinations.go b/internal/config/destinations.go index f3ca4acb..13f00c39 100644 --- a/internal/config/destinations.go +++ b/internal/config/destinations.go @@ -35,6 +35,10 @@ func (c *DestinationsConfig) ToConfig(cfg *Config) destregistrydefault.RegisterD // Webhook configuration type DestinationWebhookConfig struct { + // ProxyURL may contain authentication credentials (e.g., http://user:pass@proxy:8080) + // and should be treated as sensitive. + // TODO: Implement sensitive value handling - https://github.com/hookdeck/outpost/issues/480 + ProxyURL string `yaml:"proxy_url" env:"DESTINATIONS_WEBHOOK_PROXY_URL" desc:"Proxy URL for routing webhook requests through a proxy server. Supports HTTP and HTTPS proxies. When configured, all outgoing webhook traffic will be routed through the specified proxy." required:"N"` HeaderPrefix string `yaml:"header_prefix" env:"DESTINATIONS_WEBHOOK_HEADER_PREFIX" desc:"Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-')." required:"N"` DisableDefaultEventIDHeader bool `yaml:"disable_default_event_id_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER" desc:"If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests." required:"N"` DisableDefaultSignatureHeader bool `yaml:"disable_default_signature_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER" desc:"If true, disables adding the default 'X-Outpost-Signature' header to webhook requests." required:"N"` @@ -49,6 +53,7 @@ type DestinationWebhookConfig struct { // toConfig converts WebhookConfig to the provider config - private since it's only used internally func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookConfig { return &destregistrydefault.DestWebhookConfig{ + ProxyURL: c.ProxyURL, HeaderPrefix: c.HeaderPrefix, DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader, DisableDefaultSignatureHeader: c.DisableDefaultSignatureHeader, diff --git a/internal/destregistry/baseprovider.go b/internal/destregistry/baseprovider.go index 4f4c7ecc..ecf4f613 100644 --- a/internal/destregistry/baseprovider.go +++ b/internal/destregistry/baseprovider.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" "regexp" "strconv" "strings" @@ -200,28 +201,51 @@ func (p *BaseProvider) Preprocess(newDestination *models.Destination, originalDe type HTTPClientConfig struct { Timeout *time.Duration UserAgent *string + ProxyURL *string } -func (p *BaseProvider) MakeHTTPClient(config HTTPClientConfig) *http.Client { +func (p *BaseProvider) MakeHTTPClient(config HTTPClientConfig) (*http.Client, error) { client := &http.Client{} if config.Timeout != nil { client.Timeout = *config.Timeout } - if config.UserAgent != nil { - client.Transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) { - req.Header.Set("User-Agent", *config.UserAgent) - return http.DefaultTransport.RoundTrip(req) - }) + // Configure transport with proxy and/or user agent if needed + if config.ProxyURL != nil || config.UserAgent != nil { + // Start with default transport settings + transport := http.DefaultTransport.(*http.Transport).Clone() + + // Configure proxy if provided + if config.ProxyURL != nil && *config.ProxyURL != "" { + proxyURLParsed, err := url.Parse(*config.ProxyURL) + if err != nil { + return nil, fmt.Errorf("invalid proxy URL: %w", err) + } + transport.Proxy = http.ProxyURL(proxyURLParsed) + } + + // Wrap transport with user agent if needed + if config.UserAgent != nil { + client.Transport = &userAgentTransport{ + userAgent: *config.UserAgent, + transport: transport, + } + } else { + client.Transport = transport + } } - return client + return client, nil } -// Helper type for custom RoundTripper -type roundTripperFunc func(*http.Request) (*http.Response, error) +// userAgentTransport wraps an http.RoundTripper to inject a User-Agent header +type userAgentTransport struct { + userAgent string + transport http.RoundTripper +} -func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) +func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", t.userAgent) + return t.transport.RoundTrip(req) } diff --git a/internal/destregistry/baseprovider_test.go b/internal/destregistry/baseprovider_test.go new file mode 100644 index 00000000..58c20953 --- /dev/null +++ b/internal/destregistry/baseprovider_test.go @@ -0,0 +1,120 @@ +package destregistry_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hookdeck/outpost/internal/destregistry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMakeHTTPClient_UserAgent(t *testing.T) { + t.Parallel() + + provider, err := newMockProvider() + require.NoError(t, err) + + t.Run("sets user agent on requests", func(t *testing.T) { + t.Parallel() + + // Create a test server that captures the User-Agent header + var capturedUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedUserAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create client with user agent + userAgent := "TestAgent/1.0" + client, err := provider.MakeHTTPClient(destregistry.HTTPClientConfig{ + UserAgent: &userAgent, + }) + require.NoError(t, err) + + // Make a request + resp, err := client.Get(server.URL) + require.NoError(t, err) + defer resp.Body.Close() + io.ReadAll(resp.Body) + + // Verify user agent was set + assert.Equal(t, "TestAgent/1.0", capturedUserAgent) + }) + + t.Run("handles empty user agent string", func(t *testing.T) { + t.Parallel() + + emptyUserAgent := "" + client, err := provider.MakeHTTPClient(destregistry.HTTPClientConfig{ + UserAgent: &emptyUserAgent, + }) + require.NoError(t, err) + + // Should still create a valid client + assert.NotNil(t, client) + assert.NotNil(t, client.Transport) + }) +} + +func TestMakeHTTPClient_Proxy(t *testing.T) { + t.Parallel() + + provider, err := newMockProvider() + require.NoError(t, err) + + t.Run("routes requests through proxy", func(t *testing.T) { + t.Parallel() + + // Create a proxy server that tracks requests + var proxyRequestReceived bool + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyRequestReceived = true + // For CONNECT requests (HTTPS), respond with 200 + if r.Method == http.MethodConnect { + w.WriteHeader(http.StatusOK) + return + } + // For regular HTTP requests, proxy them + w.WriteHeader(http.StatusOK) + })) + defer proxyServer.Close() + + // Create a target server + targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer targetServer.Close() + + // Create client with proxy configured + client, err := provider.MakeHTTPClient(destregistry.HTTPClientConfig{ + ProxyURL: &proxyServer.URL, + }) + require.NoError(t, err) + + // Make a request through the proxy + resp, err := client.Get(targetServer.URL) + require.NoError(t, err) + defer resp.Body.Close() + io.ReadAll(resp.Body) + + // Verify request went through proxy + assert.True(t, proxyRequestReceived, "Expected request to go through proxy") + }) + + t.Run("returns error for invalid proxy URL", func(t *testing.T) { + t.Parallel() + + invalidProxy := "://invalid-url" + _, err := provider.MakeHTTPClient(destregistry.HTTPClientConfig{ + ProxyURL: &invalidProxy, + }) + + // Should return error for invalid proxy URL + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid proxy URL") + }) +} diff --git a/internal/destregistry/providers/default.go b/internal/destregistry/providers/default.go index 8261725d..433a5e90 100644 --- a/internal/destregistry/providers/default.go +++ b/internal/destregistry/providers/default.go @@ -12,6 +12,7 @@ import ( ) type DestWebhookConfig struct { + ProxyURL string HeaderPrefix string DisableDefaultEventIDHeader bool DisableDefaultSignatureHeader bool @@ -51,6 +52,7 @@ func RegisterDefault(registry destregistry.Registry, opts RegisterDefaultDestina } if opts.Webhook != nil { webhookOpts = append(webhookOpts, + destwebhook.WithProxyURL(opts.Webhook.ProxyURL), destwebhook.WithHeaderPrefix(opts.Webhook.HeaderPrefix), destwebhook.WithDisableDefaultEventIDHeader(opts.Webhook.DisableDefaultEventIDHeader), destwebhook.WithDisableDefaultSignatureHeader(opts.Webhook.DisableDefaultSignatureHeader), diff --git a/internal/destregistry/providers/desthookdeck/desthookdeck.go b/internal/destregistry/providers/desthookdeck/desthookdeck.go index 030823c6..08386a82 100644 --- a/internal/destregistry/providers/desthookdeck/desthookdeck.go +++ b/internal/destregistry/providers/desthookdeck/desthookdeck.go @@ -149,9 +149,13 @@ func (p *HookdeckProvider) CreatePublisher(ctx context.Context, destination *mod if p.httpClient != nil { client = p.httpClient } else { - client = p.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{ + var err error + client, err = p.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{ UserAgent: &p.userAgent, }) + if err != nil { + return nil, err + } } // Create publisher with base publisher from provider diff --git a/internal/destregistry/providers/destwebhook/destwebhook.go b/internal/destregistry/providers/destwebhook/destwebhook.go index 2729d265..39493ee9 100644 --- a/internal/destregistry/providers/destwebhook/destwebhook.go +++ b/internal/destregistry/providers/destwebhook/destwebhook.go @@ -26,6 +26,7 @@ type WebhookDestination struct { *destregistry.BaseProvider headerPrefix string userAgent string + proxyURL string signatureContentTemplate string signatureHeaderTemplate string disableEventIDHeader bool @@ -71,6 +72,12 @@ func WithUserAgent(userAgent string) Option { } } +func WithProxyURL(proxyURL string) Option { + return func(w *WebhookDestination) { + w.proxyURL = proxyURL + } +} + // Add these options after the existing Option definitions func WithDisableDefaultEventIDHeader(disable bool) Option { return func(w *WebhookDestination) { @@ -213,9 +220,18 @@ func (d *WebhookDestination) CreatePublisher(ctx context.Context, destination *m WithAlgorithm(GetAlgorithm(d.algorithm)), ) - httpClient := d.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{ + var proxyURL *string + if d.proxyURL != "" { + proxyURL = &d.proxyURL + } + + httpClient, err := d.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{ UserAgent: &d.userAgent, + ProxyURL: proxyURL, }) + if err != nil { + return nil, err + } return &WebhookPublisher{ BasePublisher: d.BaseProvider.NewPublisher(), diff --git a/internal/destregistry/testing/publisher_suite.go b/internal/destregistry/testing/publisher_suite.go index 9e852b74..ad8f3316 100644 --- a/internal/destregistry/testing/publisher_suite.go +++ b/internal/destregistry/testing/publisher_suite.go @@ -51,8 +51,8 @@ func AssertTimestampIsUnixSeconds(t TestingT, timestampStr string, msgAndArgs .. // Current time in seconds: ~1,700,000,000 (2023-2024) // Current time in millis: ~1,700,000,000,000 - minUnixSeconds := int64(946684800) // Jan 1, 2000 - maxUnixSeconds := int64(4102444800) // Jan 1, 2100 + minUnixSeconds := int64(946684800) // Jan 1, 2000 + maxUnixSeconds := int64(4102444800) // Jan 1, 2100 if timestampInt < minUnixSeconds || timestampInt > maxUnixSeconds { // Likely milliseconds - check if dividing by 1000 gives a reasonable timestamp