Files
nmon/internal/notify/render.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
}