182 lines
5.6 KiB
Go
182 lines
5.6 KiB
Go
package notify
|
|
|
|
import "bytes"
|
|
import "context"
|
|
import "crypto/tls"
|
|
import "crypto/x509"
|
|
import "encoding/json"
|
|
import "fmt"
|
|
import "io"
|
|
import "net/http"
|
|
import "strings"
|
|
|
|
import "codit/internal/models"
|
|
|
|
// webhookConfig is the non-secret transport config (transport_providers.config).
|
|
// Method defaults to POST. HeaderName + the transport secret let a webhook carry
|
|
// an auth token (e.g. HeaderName "Authorization", secret "Bearer abc"). CARef is
|
|
// a pki_cas public id to trust for HTTPS (in addition to system roots);
|
|
// InsecureSkipVerify disables verification entirely.
|
|
type webhookConfig struct {
|
|
Method string `json:"method"`
|
|
HeaderName string `json:"header_name"`
|
|
CARef string `json:"ca_ref"`
|
|
InsecureSkipVerify bool `json:"insecure_skip_verify"`
|
|
}
|
|
|
|
// tlsConfigFor builds a *tls.Config from a transport's TLS options, shared by
|
|
// every transport that makes a TLS connection (webhook HTTPS, SMTP STARTTLS/
|
|
// SMTPS, …). Returns (nil, nil) when neither option is set (use defaults). A
|
|
// caRef that no longer resolves (e.g. the CA was deleted) fails closed — it
|
|
// returns an error rather than silently falling back to system roots, so the
|
|
// pinning intent is preserved.
|
|
func (d *Dispatcher) tlsConfigFor(insecureSkipVerify bool, caRef string) (*tls.Config, error) {
|
|
var tlsCfg *tls.Config
|
|
var pool *x509.CertPool
|
|
var ca models.PKICA
|
|
var err error
|
|
|
|
caRef = strings.TrimSpace(caRef)
|
|
if !insecureSkipVerify && caRef == "" {
|
|
return nil, nil
|
|
}
|
|
tlsCfg = &tls.Config{}
|
|
if insecureSkipVerify {
|
|
tlsCfg.InsecureSkipVerify = true
|
|
}
|
|
if caRef != "" {
|
|
if d.store == nil {
|
|
return nil, fmt.Errorf("cannot resolve CA %q: no store", caRef)
|
|
}
|
|
ca, err = d.store.GetPKICA(caRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("referenced CA %q not found; refusing to deliver", caRef)
|
|
}
|
|
pool, err = x509.SystemCertPool()
|
|
if err != nil || pool == nil {
|
|
pool = x509.NewCertPool()
|
|
}
|
|
if !pool.AppendCertsFromPEM([]byte(ca.CertPEM)) {
|
|
return nil, fmt.Errorf("CA %q has no usable certificate", caRef)
|
|
}
|
|
tlsCfg.RootCAs = pool
|
|
}
|
|
return tlsCfg, nil
|
|
}
|
|
|
|
// clientFor returns the HTTP client to deliver a webhook with. The shared client
|
|
// (system roots) is used unless TLS options are set.
|
|
func (d *Dispatcher) clientFor(cfg webhookConfig) (*http.Client, error) {
|
|
var tlsCfg *tls.Config
|
|
var err error
|
|
|
|
tlsCfg, err = d.tlsConfigFor(cfg.InsecureSkipVerify, cfg.CARef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tlsCfg == nil {
|
|
return d.client, nil
|
|
}
|
|
return &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: d.policy.DialContext,
|
|
TLSClientConfig: tlsCfg,
|
|
DisableKeepAlives: true,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// webhookPayload is the JSON body delivered for an event. Stable, transport-
|
|
// agnostic shape so receivers can parse events from any producer.
|
|
type webhookPayload struct {
|
|
Kind string `json:"kind"`
|
|
Severity string `json:"severity"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
SourceType string `json:"source_type"`
|
|
SourceID string `json:"source_id"`
|
|
Payload string `json:"payload,omitempty"`
|
|
Channel string `json:"channel"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
// Sending-deployment identity so a receiver can tell which system raised the
|
|
// alert without inspecting the URL it was delivered to.
|
|
ServiceID string `json:"service_id,omitempty"`
|
|
SiteName string `json:"site_name,omitempty"`
|
|
}
|
|
|
|
// deliverWebhook POSTs the event as JSON to the channel's address. The client's
|
|
// DialContext enforces the egress policy, so a URL resolving to an internal IP
|
|
// fails at connect (SSRF guard) rather than being delivered. secrets holds the
|
|
// decrypted secret fields (e.g. "token") for this transport.
|
|
func (d *Dispatcher) deliverWebhook(ctx context.Context, target models.DeliveryTarget, secrets map[string]string, event models.Event) error {
|
|
var cfg webhookConfig
|
|
var body []byte
|
|
var method string
|
|
var req *http.Request
|
|
var resp *http.Response
|
|
var client *http.Client
|
|
var cancel context.CancelFunc
|
|
var err error
|
|
|
|
ctx, cancel = context.WithTimeout(ctx, d.webhookTimeout())
|
|
defer cancel()
|
|
|
|
if strings.TrimSpace(target.Address) == "" {
|
|
return fmt.Errorf("webhook channel %q has no address", target.ChannelName)
|
|
}
|
|
if strings.TrimSpace(target.Config) != "" {
|
|
_ = json.Unmarshal([]byte(target.Config), &cfg)
|
|
}
|
|
// Fail closed if a pinned CA can't be resolved (deleted / bad id).
|
|
client, err = d.clientFor(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
method = strings.ToUpper(strings.TrimSpace(cfg.Method))
|
|
if method == "" {
|
|
method = http.MethodPost
|
|
}
|
|
|
|
body, err = json.Marshal(webhookPayload{
|
|
Kind: event.Kind,
|
|
Severity: event.Severity,
|
|
Title: event.Title,
|
|
Body: event.Body,
|
|
SourceType: event.SourceType,
|
|
SourceID: event.SourceID,
|
|
Payload: event.Payload,
|
|
Channel: target.ChannelName,
|
|
CreatedAt: event.CreatedAt,
|
|
ServiceID: d.serverID,
|
|
SiteName: d.siteName,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err = http.NewRequestWithContext(ctx, method, strings.TrimSpace(target.Address), bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if strings.TrimSpace(secrets["token"]) != "" {
|
|
var headerName string
|
|
headerName = strings.TrimSpace(cfg.HeaderName)
|
|
if headerName == "" {
|
|
headerName = "Authorization"
|
|
}
|
|
req.Header.Set(headerName, secrets["token"])
|
|
}
|
|
|
|
resp, err = client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Errorf("webhook %q returned status %d", target.ChannelName, resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|