/ components / egress / pkg / events / webhook.go
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  }