Skip to content

Commit 7c295f4

Browse files
authored
Merge pull request #2567 from keboola/hosan/PAT-1632
Add ForwardE2bWebhook endpoint for sandbox lifecycle webhooks
2 parents 73ff8f9 + 3a4e24e commit 7c295f4

3 files changed

Lines changed: 161 additions & 0 deletions

File tree

internal/pkg/service/appsproxy/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Config struct {
2525
SandboxesAPI SandboxesAPI `configKey:"sandboxesAPI"`
2626
CsrfTokenSalt string `configKey:"csrfTokenSalt" configUsage:"Salt used for generating CSRF tokens" validate:"required" sensitive:"true"`
2727
K8s K8s `configKey:"k8s" configUsage:"Kubernetes configuration."`
28+
E2bWebhook E2BWebhook `configKey:"e2bWebhook"`
2829
}
2930

3031
type API struct {
@@ -47,6 +48,14 @@ type K8s struct {
4748
Kubeconfig string `configKey:"kubeconfig" configUsage:"Path to kubeconfig file. Uses in-cluster config if empty."`
4849
}
4950

51+
// E2BWebhook configures the reverse-proxy endpoint that forwards E2B sandbox
52+
// lifecycle webhooks to the keboola-operator webhook server.
53+
// Signature verification is handled by the operator, not by the proxy.
54+
// When UpstreamURL is empty the endpoint is disabled.
55+
type E2BWebhook struct {
56+
UpstreamURL string `configKey:"upstreamUrl" configUsage:"Operator internal webhook URL (e.g. http://keboola-operator-e2b-webhook.keboola-operator.svc.cluster.local:19200/webhook/e2b). Empty disables the endpoint."`
57+
}
58+
5059
func New() Config {
5160
return Config{
5261
DebugLog: false,
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package proxy_test
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"strings"
9+
"sync"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/config"
16+
proxyDependencies "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dependencies"
17+
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy"
18+
"github.com/keboola/keboola-as-code/internal/pkg/service/common/dependencies"
19+
)
20+
21+
func TestForwardE2bWebhook(t *testing.T) {
22+
t.Parallel()
23+
24+
ctx := t.Context()
25+
26+
// Track what the fake operator received.
27+
var mu sync.Mutex
28+
var receivedBody string
29+
var receivedHeaders http.Header
30+
31+
// Start a fake operator webhook server.
32+
operatorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+
mu.Lock()
34+
defer mu.Unlock()
35+
36+
body, _ := io.ReadAll(r.Body)
37+
receivedBody = string(body)
38+
receivedHeaders = r.Header.Clone()
39+
w.WriteHeader(http.StatusOK)
40+
}))
41+
defer operatorServer.Close()
42+
43+
// Configure proxy with E2B webhook upstream pointing to fake operator.
44+
cfg := config.New()
45+
cfg.API.PublicURL, _ = url.Parse("https://hub.keboola.local")
46+
cfg.CsrfTokenSalt = "abc"
47+
cfg.E2bWebhook.UpstreamURL = operatorServer.URL
48+
49+
d, _ := proxyDependencies.NewMockedServiceScope(t, ctx, cfg, dependencies.WithRealHTTPClient())
50+
51+
// Create proxy handler.
52+
handler := proxy.NewHandler(ctx, d)
53+
54+
// Build a webhook request with all e2b-* headers.
55+
webhookBody := `{"version":"v2","id":"evt-1","type":"sandbox.lifecycle.killed","sandboxId":"sb-123","sandboxTeamId":"team-1","sandboxTemplateId":"tmpl-1","timestamp":"2025-08-06T20:59:24Z"}`
56+
57+
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/_proxy/api/v1/webhook/e2b", strings.NewReader(webhookBody))
58+
req.Host = "hub.keboola.local"
59+
req.Header.Set("Content-Type", "application/json")
60+
req.Header.Set("e2b-signature", "some-signature")
61+
req.Header.Set("e2b-webhook-id", "wh-456")
62+
req.Header.Set("e2b-delivery-id", "del-789")
63+
req.Header.Set("e2b-signature-version", "v1")
64+
65+
rec := httptest.NewRecorder()
66+
handler.ServeHTTP(rec, req)
67+
68+
// Verify response.
69+
require.Equal(t, http.StatusOK, rec.Code)
70+
71+
// Verify the fake operator received the correct body.
72+
mu.Lock()
73+
defer mu.Unlock()
74+
assert.Equal(t, webhookBody, receivedBody)
75+
76+
// Verify all e2b-* headers were forwarded.
77+
assert.Equal(t, "some-signature", receivedHeaders.Get("e2b-signature"))
78+
assert.Equal(t, "wh-456", receivedHeaders.Get("e2b-webhook-id"))
79+
assert.Equal(t, "del-789", receivedHeaders.Get("e2b-delivery-id"))
80+
assert.Equal(t, "v1", receivedHeaders.Get("e2b-signature-version"))
81+
assert.Equal(t, "application/json", receivedHeaders.Get("Content-Type"))
82+
}
83+
84+
func TestForwardE2bWebhookUpstreamError(t *testing.T) {
85+
t.Parallel()
86+
87+
ctx := t.Context()
88+
89+
// Operator rejects requests with invalid/missing signature.
90+
operatorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91+
http.Error(w, "invalid signature", http.StatusUnauthorized)
92+
}))
93+
defer operatorServer.Close()
94+
95+
cfg := config.New()
96+
cfg.API.PublicURL, _ = url.Parse("https://hub.keboola.local")
97+
cfg.CsrfTokenSalt = "abc"
98+
cfg.E2bWebhook.UpstreamURL = operatorServer.URL
99+
100+
d, _ := proxyDependencies.NewMockedServiceScope(t, ctx, cfg, dependencies.WithRealHTTPClient())
101+
102+
handler := proxy.NewHandler(ctx, d)
103+
104+
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/_proxy/api/v1/webhook/e2b", strings.NewReader(`{"sandboxId":"sb-1"}`))
105+
req.Host = "hub.keboola.local"
106+
req.Header.Set("Content-Type", "application/json")
107+
req.Header.Set("e2b-signature", "invalid-signature")
108+
rec := httptest.NewRecorder()
109+
handler.ServeHTTP(rec, req)
110+
111+
// Reverse proxy propagates the upstream status code directly.
112+
assert.Equal(t, http.StatusUnauthorized, rec.Code)
113+
}
114+
115+
func TestForwardE2bWebhookDisabled(t *testing.T) {
116+
t.Parallel()
117+
118+
ctx := t.Context()
119+
120+
// Configure proxy WITHOUT E2B webhook upstream (disabled).
121+
cfg := config.New()
122+
cfg.API.PublicURL, _ = url.Parse("https://hub.keboola.local")
123+
cfg.CsrfTokenSalt = "abc"
124+
// cfg.E2BWebhook.UpstreamURL is empty — reverse proxy not mounted.
125+
126+
d, _ := proxyDependencies.NewMockedServiceScope(t, ctx, cfg, dependencies.WithRealHTTPClient())
127+
128+
handler := proxy.NewHandler(ctx, d)
129+
130+
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/_proxy/api/v1/webhook/e2b", strings.NewReader(`{}`))
131+
req.Host = "hub.keboola.local"
132+
rec := httptest.NewRecorder()
133+
handler.ServeHTTP(rec, req)
134+
135+
// When disabled, the endpoint is not mounted — expect 404.
136+
assert.Equal(t, http.StatusNotFound, rec.Code)
137+
}

internal/pkg/service/appsproxy/proxy/server.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package proxy
33
import (
44
"context"
55
"net/http"
6+
"net/http/httputil"
7+
"net/url"
68
"strings"
79
"time"
810

@@ -126,6 +128,19 @@ func NewHandler(ctx context.Context, d dependencies.ServiceScope) http.Handler {
126128
// Register static assets
127129
d.PageWriter().MountAssets(mux)
128130

131+
// Mount E2B webhook reverse proxy.
132+
// Forwards the request unchanged to the keboola-operator webhook server.
133+
// Signature verification is handled by the operator, not by the proxy.
134+
if raw := d.Config().E2bWebhook.UpstreamURL; raw != "" {
135+
target, err := url.Parse(raw)
136+
if err == nil {
137+
mux.Handle("/_proxy/api/v1/webhook/e2b", &httputil.ReverseProxy{
138+
Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(target) },
139+
Transport: d.UpstreamTransport(),
140+
})
141+
}
142+
}
143+
129144
// Create service
130145
svc, err := service.New(ctx, d)
131146
if err != nil {

0 commit comments

Comments
 (0)