webhook.go
1 // Copyright 2026 Alibaba Group Holding Ltd. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package events 16 17 import ( 18 "bytes" 19 "context" 20 "encoding/json" 21 "fmt" 22 "io" 23 "net/http" 24 "os" 25 "time" 26 27 "github.com/alibaba/opensandbox/egress/pkg/constants" 28 "github.com/alibaba/opensandbox/egress/pkg/log" 29 ) 30 31 const ( 32 webhookSource = "opensandbox-egress" 33 defaultWebhookTimeout = 5 * time.Second 34 defaultWebhookRetries = 3 35 defaultWebhookBackoff = 1 * time.Second 36 ) 37 38 // WebhookSubscriber delivers blocked events to an HTTP endpoint. 39 type WebhookSubscriber struct { 40 url string 41 client *http.Client 42 timeout time.Duration 43 maxRetries int 44 backoff time.Duration 45 sandboxID string 46 } 47 48 type webhookPayload struct { 49 Hostname string `json:"hostname"` 50 Timestamp string `json:"timestamp"` 51 Source string `json:"source"` 52 SandboxID string `json:"sandboxId"` 53 } 54 55 // NewWebhookSubscriber builds a webhook subscriber with hardcoded timeout/retry settings. 56 func NewWebhookSubscriber(url string) *WebhookSubscriber { 57 if url == "" { 58 return nil 59 } 60 return &WebhookSubscriber{ 61 url: url, 62 client: &http.Client{}, 63 timeout: defaultWebhookTimeout, 64 maxRetries: defaultWebhookRetries, 65 backoff: defaultWebhookBackoff, 66 sandboxID: os.Getenv(constants.EnvSandboxID), 67 } 68 } 69 70 // HandleBlocked sends the blocked event to the configured webhook with retries. 71 func (w *WebhookSubscriber) HandleBlocked(ctx context.Context, ev BlockedEvent) { 72 payload := webhookPayload{ 73 Hostname: ev.Hostname, 74 Timestamp: ev.Timestamp.UTC().Format(time.RFC3339), 75 Source: webhookSource, 76 SandboxID: w.sandboxID, 77 } 78 body, err := json.Marshal(payload) 79 if err != nil { 80 log.Warnf("[webhook] failed to marshal payload for hostname %s: %v", ev.Hostname, err) 81 return 82 } 83 84 var lastErr error 85 for attempt := 0; attempt <= w.maxRetries; attempt++ { 86 reqCtx := ctx 87 cancel := func() {} 88 if w.timeout > 0 { 89 reqCtx, cancel = context.WithTimeout(ctx, w.timeout) 90 } 91 92 req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, w.url, bytes.NewReader(body)) 93 if err != nil { 94 cancel() 95 lastErr = err 96 break 97 } 98 req.Header.Set("Content-Type", "application/json") 99 100 resp, err := w.client.Do(req) 101 if err == nil { 102 _, _ = io.Copy(io.Discard, resp.Body) 103 _ = resp.Body.Close() 104 if resp.StatusCode < 300 { 105 cancel() 106 return 107 } 108 if resp.StatusCode < 500 { 109 cancel() 110 log.Warnf("[webhook] non-retriable status %d for hostname %s", resp.StatusCode, payload.Hostname) 111 return 112 } 113 err = fmt.Errorf("status %d", resp.StatusCode) 114 } 115 116 cancel() 117 lastErr = err 118 if attempt < w.maxRetries { 119 time.Sleep(w.backoff * time.Duration(1<<attempt)) 120 } 121 } 122 123 if lastErr != nil { 124 log.Warnf("[webhook] failed to notify hostname %s after retries: %v", payload.Hostname, lastErr) 125 } 126 }