Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
180 lines
5.1 KiB
Go
180 lines
5.1 KiB
Go
package notify
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"net/url"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
)
|
|
|
|
// DefaultWebhookTemplate sends the whole Notification as JSON.
|
|
const DefaultWebhookTemplate = `{{json .}}`
|
|
|
|
// DefaultSMTPTemplate is "Subject: ...", a blank line, then a plain-text body.
|
|
const DefaultSMTPTemplate = `Subject: [{{upper .State}}] {{.Summary}}
|
|
|
|
{{.Summary}}
|
|
{{if .Description}}
|
|
{{.Description}}
|
|
{{end}}
|
|
Rule: {{.Rule}}
|
|
State: {{.State}}
|
|
Severity: {{.Severity}}
|
|
Device: {{.Device.Name}} ({{.Device.Slug}})
|
|
{{- if .Interface.ID}}
|
|
Interface: {{.Interface.Name}} {{.Interface.Alias}} ({{.Interface.ID}})
|
|
{{- end}}
|
|
Fired: {{timefmt .FiredAt "2006-01-02 15:04:05 MST"}}
|
|
{{- if .ClearedAt}}
|
|
Cleared: {{timefmt .ClearedAt "2006-01-02 15:04:05 MST"}}
|
|
{{- end}}
|
|
{{- if .Parents}}
|
|
Parents down: {{join .Parents ", "}}
|
|
{{- end}}
|
|
{{- if .SuppressedChildren}}
|
|
Suppressed children: {{.SuppressedChildren}}
|
|
{{- end}}
|
|
{{- if .URL}}
|
|
Link: {{.URL}}
|
|
{{- end}}
|
|
Incident: {{.IncidentID}}
|
|
`
|
|
|
|
const maxPayload = 256 << 10
|
|
|
|
// funcs is the whole template function set.
|
|
var funcs = template.FuncMap{
|
|
"json": func(v any) (string, error) {
|
|
b, err := json.Marshal(v)
|
|
return string(b), err
|
|
},
|
|
"upper": strings.ToUpper,
|
|
"lower": strings.ToLower,
|
|
"join": strings.Join,
|
|
// timefmt formats a time.Time or *time.Time in UTC; nil gives "".
|
|
"timefmt": func(v any, layout string) (string, error) {
|
|
switch t := v.(type) {
|
|
case time.Time:
|
|
return t.UTC().Format(layout), nil
|
|
case *time.Time:
|
|
if t == nil {
|
|
return "", nil
|
|
}
|
|
return t.UTC().Format(layout), nil
|
|
}
|
|
return "", fmt.Errorf("timefmt: %T is not a time", v)
|
|
},
|
|
}
|
|
|
|
// compiled is a validated Receiver with its parsed template.
|
|
type compiled struct {
|
|
Receiver
|
|
tpl *template.Template
|
|
}
|
|
|
|
// compile validates r and parses its template. The per-kind checks live in
|
|
// validateWebhook and validateSMTP, each returning the default template body.
|
|
func compile(r Receiver) (*compiled, error) {
|
|
if r.Name == "" {
|
|
return nil, errors.New("receiver has no name")
|
|
}
|
|
var body string
|
|
var err error
|
|
switch r.Kind {
|
|
case KindWebhook:
|
|
body, err = validateWebhook(r)
|
|
case KindSMTP:
|
|
body, err = validateSMTP(r)
|
|
default:
|
|
err = fmt.Errorf("unknown kind %q", r.Kind)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("receiver %q: %w", r.Name, err)
|
|
}
|
|
t, err := template.New(r.Name).Funcs(funcs).Parse(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("receiver %q: template: %w", r.Name, err)
|
|
}
|
|
c := &compiled{Receiver: r, tpl: t}
|
|
// Dry run on a populated notification: catches references outside Notification.
|
|
// The instant is arbitrary; a fixed one keeps compile free of wall-clock reads.
|
|
now := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
sample := Notification{IncidentID: "1-1", AlertID: 1, Rule: "r", State: StateFiring, Severity: "critical", Summary: "s",
|
|
FiredAt: now, ClearedAt: &now, Labels: map[string]string{"k": "v"}, Parents: []string{"p"}}
|
|
if _, err := c.render(sample); err != nil {
|
|
return nil, fmt.Errorf("receiver %q: %w", r.Name, err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// validateWebhook checks a webhook receiver and returns the template body to parse.
|
|
func validateWebhook(r Receiver) (string, error) {
|
|
u, err := url.Parse(r.URL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
|
|
return "", errors.New("url must be an absolute http(s) URL")
|
|
}
|
|
for k, v := range r.Headers {
|
|
if k == "" || strings.Contains(k, ":") || strings.ContainsAny(k+v, "\r\n") {
|
|
return "", fmt.Errorf("invalid header %q", k)
|
|
}
|
|
}
|
|
return cmp.Or(r.Template, DefaultWebhookTemplate), nil
|
|
}
|
|
|
|
// validateSMTP checks an SMTP receiver and returns the template body to parse.
|
|
func validateSMTP(r Receiver) (string, error) {
|
|
s := r.SMTP
|
|
if s.Host == "" || s.From == "" || len(s.To) == 0 {
|
|
return "", errors.New("smtp needs host, from and to")
|
|
}
|
|
for _, a := range append([]string{s.From}, s.To...) {
|
|
if strings.ContainsAny(a, "\r\n") {
|
|
return "", errors.New("invalid address")
|
|
}
|
|
if _, err := mail.ParseAddress(a); err != nil {
|
|
return "", fmt.Errorf("invalid address %q", a)
|
|
}
|
|
}
|
|
return cmp.Or(r.Template, DefaultSMTPTemplate), nil
|
|
}
|
|
|
|
// render executes the template against a safe copy of n and redacts the
|
|
// result. The returned string is what is stored, logged and sent.
|
|
func (c *compiled) render(n Notification) (string, error) {
|
|
w := &limitWriter{max: maxPayload}
|
|
if err := c.tpl.Execute(w, n.safeCopy()); err != nil {
|
|
return "", fmt.Errorf("render template: %w", err)
|
|
}
|
|
out := w.buf.String()
|
|
if c.Kind == KindWebhook {
|
|
b, err := RedactJSON([]byte(out))
|
|
if err != nil {
|
|
return "", fmt.Errorf("render template: %w", err)
|
|
}
|
|
return string(b), nil
|
|
}
|
|
return RedactText(out), nil
|
|
}
|
|
|
|
// ValidateReceivers checks a receiver set the way New does (kinds, URLs,
|
|
// addresses, template parse and dry run, duplicate names) without a queue, so
|
|
// configuration loading can refuse a bad receiver before anything starts.
|
|
func ValidateReceivers(rs []Receiver) error {
|
|
seen := make(map[string]bool, len(rs))
|
|
for _, r := range rs {
|
|
if _, err := compile(r); err != nil {
|
|
return err
|
|
}
|
|
if seen[r.Name] {
|
|
return fmt.Errorf("duplicate receiver %q", r.Name)
|
|
}
|
|
seen[r.Name] = true
|
|
}
|
|
return nil
|
|
}
|