83 lines
2.8 KiB
Go
83 lines
2.8 KiB
Go
// Package webhook owns the generation callback wire and transport policy.
|
|
package webhook
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// NewPublicHTTPSender constructs the production sender with a transport that
|
|
// resolves, validates, and then directly dials the same public IP answer.
|
|
func NewPublicHTTPSender(timeout time.Duration, policy *PublicDestinationValidator) (*HTTPSender, error) {
|
|
if policy == nil || timeout <= 0 {
|
|
return nil, errors.New("webhook HTTP sender requires a bounded client and destination policy")
|
|
}
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
transport.Proxy = nil
|
|
transport.DialContext = policy.DialContext
|
|
return NewHTTPSender(&http.Client{Timeout: timeout, Transport: transport}, policy.Validate)
|
|
}
|
|
|
|
// HTTPSender is a bounded callback transport. Destination policy remains
|
|
// injectable at composition time so deployments can enforce DNS/IP controls
|
|
// without coupling the byte/HMAC contract to one network environment.
|
|
type HTTPSender struct {
|
|
client *http.Client
|
|
validate func(context.Context, *url.URL) error
|
|
}
|
|
|
|
func NewHTTPSender(client *http.Client, validate func(context.Context, *url.URL) error) (*HTTPSender, error) {
|
|
if client == nil || client.Timeout <= 0 || validate == nil {
|
|
return nil, errors.New("webhook HTTP sender requires a bounded client and destination policy")
|
|
}
|
|
copy := *client
|
|
previous := copy.CheckRedirect
|
|
copy.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 {
|
|
return errors.New("webhook redirect limit exceeded")
|
|
}
|
|
if err := validate(request.Context(), request.URL); err != nil {
|
|
return err
|
|
}
|
|
if previous != nil {
|
|
return previous(request, via)
|
|
}
|
|
return nil
|
|
}
|
|
return &HTTPSender{client: ©, validate: validate}, nil
|
|
}
|
|
|
|
func (sender *HTTPSender) Send(ctx context.Context, input Request) (Response, error) {
|
|
target, err := url.Parse(input.URL)
|
|
if err != nil || (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" || target.User != nil {
|
|
return Response{}, errors.New("webhook destination is invalid")
|
|
}
|
|
if err := sender.validate(ctx, target); err != nil {
|
|
return Response{}, errors.New("webhook destination is not allowed")
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(input.Body))
|
|
if err != nil {
|
|
return Response{}, errors.New("build webhook request")
|
|
}
|
|
for key, value := range input.Headers {
|
|
request.Header.Set(key, value)
|
|
}
|
|
response, err := sender.client.Do(request)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return Response{}, ctx.Err()
|
|
}
|
|
return Response{}, errors.New("send webhook request")
|
|
}
|
|
defer response.Body.Close()
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
|
|
return Response{Status: response.StatusCode}, nil
|
|
}
|
|
|
|
var _ Sender = (*HTTPSender)(nil)
|