Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/pages/references/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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"

Expand Down
8 changes: 4 additions & 4 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/config/destinations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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,
Expand Down
46 changes: 35 additions & 11 deletions internal/destregistry/baseprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
Expand Down Expand Up @@ -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)
}
120 changes: 120 additions & 0 deletions internal/destregistry/baseprovider_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
2 changes: 2 additions & 0 deletions internal/destregistry/providers/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
)

type DestWebhookConfig struct {
ProxyURL string
HeaderPrefix string
DisableDefaultEventIDHeader bool
DisableDefaultSignatureHeader bool
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion internal/destregistry/providers/desthookdeck/desthookdeck.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion internal/destregistry/providers/destwebhook/destwebhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type WebhookDestination struct {
*destregistry.BaseProvider
headerPrefix string
userAgent string
proxyURL string
signatureContentTemplate string
signatureHeaderTemplate string
disableEventIDHeader bool
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions internal/destregistry/testing/publisher_suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading