// 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)