Files

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
}