Skip to content

Commit 902c016

Browse files
alexluongleggetter
andauthored
feat: support webhook delivery through proxy (#513)
* feat: implement http proxy support for destwebhook * refactor: return error * test: http client user agent * test: proxy * docs: generate config * chore: gofmt * chore: remove unused func * chore: add comment to mark proxy url as sensitive --------- Co-authored-by: Phil Leggetter <phil@leggetter.co.uk>
1 parent 4b32fcc commit 902c016

File tree

9 files changed

+194
-19
lines changed

9 files changed

+194
-19
lines changed

docs/pages/references/configuration.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Global configurations are provided through env variables or a YAML file. ConfigM
5151
| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. | `false` | No |
5252
| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. | `false` | No |
5353
| `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-'). | `x-outpost-` | No |
54+
| `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 |
5455
| `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM` | Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). | `hmac-sha256` | No |
5556
| `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | Go template for constructing the content to be signed for webhook requests. | `{{.Timestamp.Unix}}.{{.Body}}` | No |
5657
| `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | Encoding for the signature (e.g., 'hex', 'base64'). | `hex` | No |
@@ -196,6 +197,9 @@ destinations:
196197
# Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-').
197198
header_prefix: "x-outpost-"
198199

200+
# 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.
201+
proxy_url: ""
202+
199203
# Algorithm used for signing webhook requests (e.g., 'hmac-sha256').
200204
signature_algorithm: "hmac-sha256"
201205

internal/app/app.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,10 @@ func constructServices(
198198
// MIGRATION LOCK BEHAVIOR:
199199
// - Database locks are only acquired when migrations need to be performed
200200
// - When multiple nodes start simultaneously and migrations are pending:
201-
// 1. One node acquires the lock and performs migrations (ideally < 5 seconds)
202-
// 2. Other nodes fail with lock errors ("try lock failed", "can't acquire lock")
203-
// 3. Failed nodes wait 5 seconds and retry
204-
// 4. On retry, migrations are complete and nodes proceed successfully
201+
// 1. One node acquires the lock and performs migrations (ideally < 5 seconds)
202+
// 2. Other nodes fail with lock errors ("try lock failed", "can't acquire lock")
203+
// 3. Failed nodes wait 5 seconds and retry
204+
// 4. On retry, migrations are complete and nodes proceed successfully
205205
//
206206
// RETRY STRATEGY:
207207
// - Max 3 attempts with 5-second delays between retries

internal/config/destinations.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ func (c *DestinationsConfig) ToConfig(cfg *Config) destregistrydefault.RegisterD
3535

3636
// Webhook configuration
3737
type DestinationWebhookConfig struct {
38+
// ProxyURL may contain authentication credentials (e.g., http://user:pass@proxy:8080)
39+
// and should be treated as sensitive.
40+
// TODO: Implement sensitive value handling - https://github.com/hookdeck/outpost/issues/480
41+
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"`
3842
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"`
3943
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"`
4044
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 {
4953
// toConfig converts WebhookConfig to the provider config - private since it's only used internally
5054
func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookConfig {
5155
return &destregistrydefault.DestWebhookConfig{
56+
ProxyURL: c.ProxyURL,
5257
HeaderPrefix: c.HeaderPrefix,
5358
DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader,
5459
DisableDefaultSignatureHeader: c.DisableDefaultSignatureHeader,

internal/destregistry/baseprovider.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"net/url"
78
"regexp"
89
"strconv"
910
"strings"
@@ -200,28 +201,51 @@ func (p *BaseProvider) Preprocess(newDestination *models.Destination, originalDe
200201
type HTTPClientConfig struct {
201202
Timeout *time.Duration
202203
UserAgent *string
204+
ProxyURL *string
203205
}
204206

205-
func (p *BaseProvider) MakeHTTPClient(config HTTPClientConfig) *http.Client {
207+
func (p *BaseProvider) MakeHTTPClient(config HTTPClientConfig) (*http.Client, error) {
206208
client := &http.Client{}
207209

208210
if config.Timeout != nil {
209211
client.Timeout = *config.Timeout
210212
}
211213

212-
if config.UserAgent != nil {
213-
client.Transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) {
214-
req.Header.Set("User-Agent", *config.UserAgent)
215-
return http.DefaultTransport.RoundTrip(req)
216-
})
214+
// Configure transport with proxy and/or user agent if needed
215+
if config.ProxyURL != nil || config.UserAgent != nil {
216+
// Start with default transport settings
217+
transport := http.DefaultTransport.(*http.Transport).Clone()
218+
219+
// Configure proxy if provided
220+
if config.ProxyURL != nil && *config.ProxyURL != "" {
221+
proxyURLParsed, err := url.Parse(*config.ProxyURL)
222+
if err != nil {
223+
return nil, fmt.Errorf("invalid proxy URL: %w", err)
224+
}
225+
transport.Proxy = http.ProxyURL(proxyURLParsed)
226+
}
227+
228+
// Wrap transport with user agent if needed
229+
if config.UserAgent != nil {
230+
client.Transport = &userAgentTransport{
231+
userAgent: *config.UserAgent,
232+
transport: transport,
233+
}
234+
} else {
235+
client.Transport = transport
236+
}
217237
}
218238

219-
return client
239+
return client, nil
220240
}
221241

222-
// Helper type for custom RoundTripper
223-
type roundTripperFunc func(*http.Request) (*http.Response, error)
242+
// userAgentTransport wraps an http.RoundTripper to inject a User-Agent header
243+
type userAgentTransport struct {
244+
userAgent string
245+
transport http.RoundTripper
246+
}
224247

225-
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
226-
return f(req)
248+
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
249+
req.Header.Set("User-Agent", t.userAgent)
250+
return t.transport.RoundTrip(req)
227251
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package destregistry_test
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/hookdeck/outpost/internal/destregistry"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestMakeHTTPClient_UserAgent(t *testing.T) {
15+
t.Parallel()
16+
17+
provider, err := newMockProvider()
18+
require.NoError(t, err)
19+
20+
t.Run("sets user agent on requests", func(t *testing.T) {
21+
t.Parallel()
22+
23+
// Create a test server that captures the User-Agent header
24+
var capturedUserAgent string
25+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+
capturedUserAgent = r.Header.Get("User-Agent")
27+
w.WriteHeader(http.StatusOK)
28+
}))
29+
defer server.Close()
30+
31+
// Create client with user agent
32+
userAgent := "TestAgent/1.0"
33+
client, err := provider.MakeHTTPClient(destregistry.HTTPClientConfig{
34+
UserAgent: &userAgent,
35+
})
36+
require.NoError(t, err)
37+
38+
// Make a request
39+
resp, err := client.Get(server.URL)
40+
require.NoError(t, err)
41+
defer resp.Body.Close()
42+
io.ReadAll(resp.Body)
43+
44+
// Verify user agent was set
45+
assert.Equal(t, "TestAgent/1.0", capturedUserAgent)
46+
})
47+
48+
t.Run("handles empty user agent string", func(t *testing.T) {
49+
t.Parallel()
50+
51+
emptyUserAgent := ""
52+
client, err := provider.MakeHTTPClient(destregistry.HTTPClientConfig{
53+
UserAgent: &emptyUserAgent,
54+
})
55+
require.NoError(t, err)
56+
57+
// Should still create a valid client
58+
assert.NotNil(t, client)
59+
assert.NotNil(t, client.Transport)
60+
})
61+
}
62+
63+
func TestMakeHTTPClient_Proxy(t *testing.T) {
64+
t.Parallel()
65+
66+
provider, err := newMockProvider()
67+
require.NoError(t, err)
68+
69+
t.Run("routes requests through proxy", func(t *testing.T) {
70+
t.Parallel()
71+
72+
// Create a proxy server that tracks requests
73+
var proxyRequestReceived bool
74+
proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+
proxyRequestReceived = true
76+
// For CONNECT requests (HTTPS), respond with 200
77+
if r.Method == http.MethodConnect {
78+
w.WriteHeader(http.StatusOK)
79+
return
80+
}
81+
// For regular HTTP requests, proxy them
82+
w.WriteHeader(http.StatusOK)
83+
}))
84+
defer proxyServer.Close()
85+
86+
// Create a target server
87+
targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88+
w.WriteHeader(http.StatusOK)
89+
}))
90+
defer targetServer.Close()
91+
92+
// Create client with proxy configured
93+
client, err := provider.MakeHTTPClient(destregistry.HTTPClientConfig{
94+
ProxyURL: &proxyServer.URL,
95+
})
96+
require.NoError(t, err)
97+
98+
// Make a request through the proxy
99+
resp, err := client.Get(targetServer.URL)
100+
require.NoError(t, err)
101+
defer resp.Body.Close()
102+
io.ReadAll(resp.Body)
103+
104+
// Verify request went through proxy
105+
assert.True(t, proxyRequestReceived, "Expected request to go through proxy")
106+
})
107+
108+
t.Run("returns error for invalid proxy URL", func(t *testing.T) {
109+
t.Parallel()
110+
111+
invalidProxy := "://invalid-url"
112+
_, err := provider.MakeHTTPClient(destregistry.HTTPClientConfig{
113+
ProxyURL: &invalidProxy,
114+
})
115+
116+
// Should return error for invalid proxy URL
117+
require.Error(t, err)
118+
assert.Contains(t, err.Error(), "invalid proxy URL")
119+
})
120+
}

internal/destregistry/providers/default.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
)
1313

1414
type DestWebhookConfig struct {
15+
ProxyURL string
1516
HeaderPrefix string
1617
DisableDefaultEventIDHeader bool
1718
DisableDefaultSignatureHeader bool
@@ -51,6 +52,7 @@ func RegisterDefault(registry destregistry.Registry, opts RegisterDefaultDestina
5152
}
5253
if opts.Webhook != nil {
5354
webhookOpts = append(webhookOpts,
55+
destwebhook.WithProxyURL(opts.Webhook.ProxyURL),
5456
destwebhook.WithHeaderPrefix(opts.Webhook.HeaderPrefix),
5557
destwebhook.WithDisableDefaultEventIDHeader(opts.Webhook.DisableDefaultEventIDHeader),
5658
destwebhook.WithDisableDefaultSignatureHeader(opts.Webhook.DisableDefaultSignatureHeader),

internal/destregistry/providers/desthookdeck/desthookdeck.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,13 @@ func (p *HookdeckProvider) CreatePublisher(ctx context.Context, destination *mod
149149
if p.httpClient != nil {
150150
client = p.httpClient
151151
} else {
152-
client = p.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{
152+
var err error
153+
client, err = p.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{
153154
UserAgent: &p.userAgent,
154155
})
156+
if err != nil {
157+
return nil, err
158+
}
155159
}
156160

157161
// Create publisher with base publisher from provider

internal/destregistry/providers/destwebhook/destwebhook.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type WebhookDestination struct {
2626
*destregistry.BaseProvider
2727
headerPrefix string
2828
userAgent string
29+
proxyURL string
2930
signatureContentTemplate string
3031
signatureHeaderTemplate string
3132
disableEventIDHeader bool
@@ -71,6 +72,12 @@ func WithUserAgent(userAgent string) Option {
7172
}
7273
}
7374

75+
func WithProxyURL(proxyURL string) Option {
76+
return func(w *WebhookDestination) {
77+
w.proxyURL = proxyURL
78+
}
79+
}
80+
7481
// Add these options after the existing Option definitions
7582
func WithDisableDefaultEventIDHeader(disable bool) Option {
7683
return func(w *WebhookDestination) {
@@ -213,9 +220,18 @@ func (d *WebhookDestination) CreatePublisher(ctx context.Context, destination *m
213220
WithAlgorithm(GetAlgorithm(d.algorithm)),
214221
)
215222

216-
httpClient := d.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{
223+
var proxyURL *string
224+
if d.proxyURL != "" {
225+
proxyURL = &d.proxyURL
226+
}
227+
228+
httpClient, err := d.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{
217229
UserAgent: &d.userAgent,
230+
ProxyURL: proxyURL,
218231
})
232+
if err != nil {
233+
return nil, err
234+
}
219235

220236
return &WebhookPublisher{
221237
BasePublisher: d.BaseProvider.NewPublisher(),

internal/destregistry/testing/publisher_suite.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ func AssertTimestampIsUnixSeconds(t TestingT, timestampStr string, msgAndArgs ..
5151
// Current time in seconds: ~1,700,000,000 (2023-2024)
5252
// Current time in millis: ~1,700,000,000,000
5353

54-
minUnixSeconds := int64(946684800) // Jan 1, 2000
55-
maxUnixSeconds := int64(4102444800) // Jan 1, 2100
54+
minUnixSeconds := int64(946684800) // Jan 1, 2000
55+
maxUnixSeconds := int64(4102444800) // Jan 1, 2100
5656

5757
if timestampInt < minUnixSeconds || timestampInt > maxUnixSeconds {
5858
// Likely milliseconds - check if dividing by 1000 gives a reasonable timestamp

0 commit comments

Comments
 (0)