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 }