ntfy-alertmanager/main.go

333 lines
7.8 KiB
Go
Raw Normal View History

2022-10-13 13:14:56 +02:00
// A bridge between ntfy and Alertmanager
2022-10-09 14:19:48 +02:00
package main
import (
"crypto/sha512"
"crypto/subtle"
_ "embed"
2022-10-10 02:42:13 +02:00
"encoding/base64"
"encoding/json"
"flag"
"fmt"
2022-10-09 14:19:48 +02:00
"net/http"
"os"
2022-10-09 14:19:48 +02:00
"strings"
"time"
2022-10-09 14:19:48 +02:00
"git.xenrox.net/~xenrox/go-log"
"golang.org/x/text/cases"
"golang.org/x/text/language"
2022-10-09 14:19:48 +02:00
)
//go:generate sh -c "git describe --long > version.txt"
//go:embed version.txt
var version string
type receiver struct {
cfg *config
logger *log.Logger
cache *cache
}
type payload struct {
Status string `json:"status"`
Alerts []alert `json:"alerts"`
GroupLabels map[string]interface{} `json:"groupLabels"`
CommonLabels map[string]interface{} `json:"commonLabels"`
CommonAnnotations map[string]interface{} `json:"commonAnnotations"`
}
type alert struct {
Status string `json:"status"`
Labels map[string]interface{} `json:"labels"`
Annotations map[string]interface{} `json:"annotations"`
Fingerprint fingerprint `json:"fingerprint"`
}
type notification struct {
title string
body string
priority string
tags string
}
func (rcv *receiver) singleAlertNotifications(p *payload) []*notification {
var notifications []*notification
for _, alert := range p.Alerts {
if rcv.cache.contains(alert.Fingerprint) {
rcv.logger.Debugf("Alert %s skipped: Still in cache", alert.Fingerprint)
continue
}
// TODO: Make configurable
rcv.cache.set(alert.Fingerprint, time.Hour*24)
n := new(notification)
// create title
n.title = fmt.Sprintf("[%s]", strings.ToUpper(alert.Status))
if name, ok := alert.Labels["alertname"]; ok {
n.title = fmt.Sprintf("%s %s", n.title, name)
}
for _, value := range p.GroupLabels {
n.title = fmt.Sprintf("%s %s", n.title, value)
}
// create body
n.body = "Labels:\n"
for key, value := range alert.Labels {
n.body = fmt.Sprintf("%s%s = %s\n", n.body, key, value)
}
n.body += "\nAnnotations:\n"
for key, value := range alert.Annotations {
n.body = fmt.Sprintf("%s%s = %s\n", n.body, key, value)
}
var tags []string
for _, labelName := range rcv.cfg.labels.Order {
val, ok := alert.Labels[labelName]
if !ok {
continue
}
labelConfig, ok := rcv.cfg.labels.Label[fmt.Sprintf("%s:%s", labelName, val)]
if !ok {
continue
}
if n.priority == "" {
n.priority = labelConfig.Priority
}
for _, val := range labelConfig.Tags {
if !sliceContains(tags, val) {
tags = append(tags, val)
}
}
}
n.tags = strings.Join(tags, ",")
notifications = append(notifications, n)
}
return notifications
}
func (rcv *receiver) multiAlertNotification(p *payload) *notification {
n := new(notification)
// create title
count := len(p.Alerts)
title := fmt.Sprintf("[%s", strings.ToUpper(p.Status))
if p.Status == "firing" {
title = fmt.Sprintf("%s:%d", title, count)
}
title += "]"
for _, value := range p.GroupLabels {
title = fmt.Sprintf("%s %s", title, value)
}
n.title = title
// create body
var body string
c := cases.Title(language.English)
for _, alert := range p.Alerts {
alertBody := fmt.Sprintf("%s\nLabels:\n", c.String(alert.Status))
for key, value := range alert.Labels {
alertBody = fmt.Sprintf("%s%s = %s\n", alertBody, key, value)
}
alertBody += "Annotations:\n"
for key, value := range alert.Annotations {
alertBody = fmt.Sprintf("%s%s = %s\n", alertBody, key, value)
}
alertBody += "\n"
body += alertBody
}
n.body = body
var priority string
var tags []string
for _, labelName := range rcv.cfg.labels.Order {
val, ok := p.CommonLabels[labelName]
if !ok {
continue
}
labelConfig, ok := rcv.cfg.labels.Label[fmt.Sprintf("%s:%s", labelName, val)]
if !ok {
continue
}
if priority == "" {
priority = labelConfig.Priority
}
for _, val := range labelConfig.Tags {
if !sliceContains(tags, val) {
tags = append(tags, val)
}
}
}
tagString := strings.Join(tags, ",")
n.priority = priority
n.tags = tagString
return n
}
func (rcv *receiver) publish(n *notification) error {
client := &http.Client{Timeout: time.Second * 3}
req, err := http.NewRequest(http.MethodPost, rcv.cfg.ntfy.Topic, strings.NewReader(n.body))
if err != nil {
return err
}
// Basic auth
if rcv.cfg.ntfy.Password != "" && rcv.cfg.ntfy.User != "" {
auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", rcv.cfg.ntfy.User, rcv.cfg.ntfy.Password)))
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", auth))
}
req.Header.Set("X-Title", n.title)
if n.priority != "" {
req.Header.Set("X-Priority", n.priority)
}
if n.tags != "" {
req.Header.Set("X-Tags", n.tags)
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ntfy: received status code %d", resp.StatusCode)
}
return nil
}
func (rcv *receiver) handleWebhooks(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
2022-10-09 20:03:55 +02:00
if r.Method != http.MethodPost {
http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
2022-10-10 14:55:22 +02:00
rcv.logger.Debugf("illegal HTTP method: expected %q, got %q", "POST", r.Method)
2022-10-09 20:03:55 +02:00
return
}
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
http.Error(w, "Only application/json allowed", http.StatusUnsupportedMediaType)
2022-10-10 14:55:22 +02:00
rcv.logger.Debugf("illegal content type: %s", contentType)
2022-10-09 20:03:55 +02:00
return
}
var event payload
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
2022-10-10 14:55:22 +02:00
rcv.logger.Debug(err)
return
}
if rcv.cfg.alertMode == single {
notifications := rcv.singleAlertNotifications(&event)
for _, n := range notifications {
err := rcv.publish(n)
if err != nil {
rcv.logger.Errorf("Failed to publish notification: %v", err)
}
}
} else {
notification := rcv.multiAlertNotification(&event)
err := rcv.publish(notification)
if err != nil {
rcv.logger.Errorf("Failed to publish notification: %v", err)
}
}
2022-10-09 14:19:48 +02:00
}
2022-10-10 19:55:33 +02:00
func (rcv *receiver) basicAuthMiddleware(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok {
rcv.logger.Debug("basic auth failure")
return
}
inputUserHash := sha512.Sum512([]byte(user))
inputPassHash := sha512.Sum512([]byte(pass))
configUserHash := sha512.Sum512([]byte(rcv.cfg.User))
configPassHash := sha512.Sum512([]byte(rcv.cfg.Password))
validUser := subtle.ConstantTimeCompare(inputUserHash[:], configUserHash[:])
validPass := subtle.ConstantTimeCompare(inputPassHash[:], configPassHash[:])
if validUser != 1 || validPass != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
2022-10-10 19:55:33 +02:00
rcv.logger.Debug("basic auth: wrong user or password")
return
}
handler(w, r)
}
}
func (rcv *receiver) runCleanup() {
for {
// TODO: Make configurable
time.Sleep(time.Hour)
rcv.logger.Info("Pruning cache")
rcv.cache.cleanup()
}
}
2022-10-09 14:19:48 +02:00
func main() {
var configPath string
flag.StringVar(&configPath, "config", "/etc/ntfy-alertmanager/config", "config file path")
var showVersion bool
flag.BoolVar(&showVersion, "version", false, "Show version and exit")
flag.Parse()
if showVersion {
fmt.Println(version)
os.Exit(0)
}
2022-10-09 14:19:48 +02:00
logger := log.NewDefaultLogger()
cfg, err := readConfig(configPath)
if err != nil {
logger.Fatalf("config: %v", err)
}
2022-10-13 01:15:04 +02:00
if err := logger.SetLevelFromString(cfg.LogLevel); err != nil {
logger.Errorf("config: %v", err)
}
receiver := &receiver{cfg: cfg, logger: logger, cache: newCache()}
logger.Infof("Listening on %s, ntfy-alertmanager %s", cfg.HTTPAddress, version)
2022-10-10 19:55:33 +02:00
if cfg.User != "" && cfg.Password != "" {
logger.Info("Enabling HTTP Basic Authentication")
http.HandleFunc("/", receiver.basicAuthMiddleware(receiver.handleWebhooks))
} else {
http.HandleFunc("/", receiver.handleWebhooks)
}
go receiver.runCleanup()
logger.Fatal(http.ListenAndServe(cfg.HTTPAddress, nil))
2022-10-09 14:19:48 +02:00
}