Setting this to "https://ntfy.sh" has security implications: If the user forgets to set his server, but uses the new short form for topics, the notification will be sent to "ntfy.sh" and could expose information.
650 lines
16 KiB
Go
650 lines
16 KiB
Go
// A bridge between ntfy and Alertmanager
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha512"
|
|
"crypto/subtle"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.xenrox.net/~xenrox/go-utils/logging"
|
|
"git.xenrox.net/~xenrox/ntfy-alertmanager/cache"
|
|
"git.xenrox.net/~xenrox/ntfy-alertmanager/config"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
var version = "dev"
|
|
|
|
const maxNTFYActions = 3
|
|
|
|
type bridge struct {
|
|
cfg *config.Config
|
|
logger *slog.Logger
|
|
cache cache.Cache
|
|
client *httpClient
|
|
}
|
|
|
|
type payload struct {
|
|
Status string `json:"status"`
|
|
Alerts []alert `json:"alerts"`
|
|
GroupLabels map[string]string `json:"groupLabels"`
|
|
CommonLabels map[string]string `json:"commonLabels"`
|
|
CommonAnnotations map[string]string `json:"commonAnnotations"`
|
|
ExternalURL string `json:"externalURL"`
|
|
}
|
|
|
|
type alert struct {
|
|
Status string `json:"status"`
|
|
Labels map[string]string `json:"labels"`
|
|
Annotations map[string]string `json:"annotations"`
|
|
GeneratorURL string `json:"generatorURL"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
}
|
|
|
|
type notification struct {
|
|
title string
|
|
body string
|
|
priority string
|
|
tags string
|
|
icon string
|
|
emailAddress string
|
|
call string
|
|
silenceBody string
|
|
fingerprint string
|
|
status string
|
|
generatorURL string
|
|
topic string
|
|
}
|
|
|
|
type ntfyError struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func (br *bridge) singleAlertNotifications(ctx context.Context, p *payload) []*notification {
|
|
var notifications []*notification
|
|
for _, alert := range p.Alerts {
|
|
contains, err := br.cache.Contains(ctx, alert.Fingerprint, alert.Status)
|
|
if err != nil {
|
|
br.logger.Error("Failed to lookup alert in cache",
|
|
slog.String("fingerprint", alert.Fingerprint),
|
|
slog.String("error", err.Error()))
|
|
}
|
|
if contains {
|
|
br.logger.Debug("Alert skipped: Still in cache",
|
|
slog.String("fingerprint", alert.Fingerprint))
|
|
continue
|
|
}
|
|
|
|
n := new(notification)
|
|
n.fingerprint = alert.Fingerprint
|
|
n.status = alert.Status
|
|
n.generatorURL = alert.GeneratorURL
|
|
|
|
// 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"
|
|
sortedLabelKeys := sortKeys(alert.Labels)
|
|
for _, key := range sortedLabelKeys {
|
|
n.body = fmt.Sprintf("%s%s = %s\n", n.body, key, alert.Labels[key])
|
|
}
|
|
|
|
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
|
|
if alert.Status == "resolved" {
|
|
tags = append(tags, br.cfg.Resolved.Tags...)
|
|
n.icon = br.cfg.Resolved.Icon
|
|
n.priority = br.cfg.Resolved.Priority
|
|
}
|
|
|
|
n.emailAddress = br.cfg.Ntfy.EmailAddress
|
|
n.call = br.cfg.Ntfy.Call
|
|
|
|
for _, labelName := range br.cfg.Labels.Order {
|
|
val, ok := alert.Labels[labelName]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
labelConfig, ok := br.cfg.Labels.Label[fmt.Sprintf("%s:%s", labelName, val)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if n.priority == "" {
|
|
n.priority = labelConfig.Priority
|
|
}
|
|
|
|
if n.icon == "" {
|
|
n.icon = labelConfig.Icon
|
|
}
|
|
|
|
if n.emailAddress == "" {
|
|
n.emailAddress = labelConfig.EmailAddress
|
|
}
|
|
|
|
if n.call == "" {
|
|
n.call = labelConfig.Call
|
|
}
|
|
|
|
if n.topic == "" {
|
|
n.topic = labelConfig.Topic
|
|
}
|
|
|
|
for _, val := range labelConfig.Tags {
|
|
if !slices.Contains(tags, val) {
|
|
tags = append(tags, val)
|
|
}
|
|
}
|
|
}
|
|
|
|
n.tags = strings.Join(tags, ",")
|
|
|
|
if br.cfg.Am.SilenceDuration != 0 && alert.Status == "firing" {
|
|
if br.cfg.BaseURL == "" {
|
|
br.logger.Error("Failed to create silence action: No base-url set")
|
|
} else {
|
|
// I could not convince ntfy to accept an Action with a body which contains
|
|
// a json with more than one key. Instead the json will be base64 encoded
|
|
// and sent to the ntfy-alertmanager silences endpoint, that operates as
|
|
// a proxy and will do the Alertmanager API request.
|
|
s := &silenceBody{AlertManagerURL: p.ExternalURL, Labels: alert.Labels}
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
br.logger.Error("Failed to create silence action",
|
|
slog.String("error", err.Error()))
|
|
}
|
|
|
|
n.silenceBody = base64.StdEncoding.EncodeToString(b)
|
|
}
|
|
}
|
|
|
|
notifications = append(notifications, n)
|
|
}
|
|
|
|
return notifications
|
|
}
|
|
|
|
func (br *bridge) 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))
|
|
|
|
sortedLabelKeys := sortKeys(alert.Labels)
|
|
for _, key := range sortedLabelKeys {
|
|
alertBody = fmt.Sprintf("%s%s = %s\n", alertBody, key, alert.Labels[key])
|
|
}
|
|
|
|
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 tags []string
|
|
if p.Status == "resolved" {
|
|
tags = append(tags, br.cfg.Resolved.Tags...)
|
|
n.icon = br.cfg.Resolved.Icon
|
|
n.priority = br.cfg.Resolved.Priority
|
|
}
|
|
|
|
n.emailAddress = br.cfg.Ntfy.EmailAddress
|
|
n.call = br.cfg.Ntfy.Call
|
|
|
|
for _, labelName := range br.cfg.Labels.Order {
|
|
val, ok := p.CommonLabels[labelName]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
labelConfig, ok := br.cfg.Labels.Label[fmt.Sprintf("%s:%s", labelName, val)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if n.priority == "" {
|
|
n.priority = labelConfig.Priority
|
|
}
|
|
|
|
if n.icon == "" {
|
|
n.icon = labelConfig.Icon
|
|
}
|
|
|
|
if n.emailAddress == "" {
|
|
n.emailAddress = labelConfig.EmailAddress
|
|
}
|
|
|
|
if n.call == "" {
|
|
n.call = labelConfig.Call
|
|
}
|
|
|
|
if n.topic == "" {
|
|
n.topic = labelConfig.Topic
|
|
}
|
|
|
|
for _, val := range labelConfig.Tags {
|
|
if !slices.Contains(tags, val) {
|
|
tags = append(tags, val)
|
|
}
|
|
}
|
|
}
|
|
|
|
n.tags = strings.Join(tags, ",")
|
|
|
|
if br.cfg.Am.SilenceDuration != 0 && p.Status == "firing" {
|
|
if br.cfg.BaseURL == "" {
|
|
br.logger.Error("Failed to create silence action: No base-url set")
|
|
} else {
|
|
|
|
s := &silenceBody{AlertManagerURL: p.ExternalURL, Labels: p.CommonLabels}
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
br.logger.Error("Failed to create silence action",
|
|
slog.String("error", err.Error()))
|
|
}
|
|
|
|
n.silenceBody = base64.StdEncoding.EncodeToString(b)
|
|
}
|
|
}
|
|
|
|
return n
|
|
}
|
|
|
|
func (br *bridge) publish(n *notification, topicParam string) error {
|
|
// precedence: topicParam > n.topic > cfg.Ntfy.Topic
|
|
if topicParam == "" {
|
|
topicParam = n.topic
|
|
}
|
|
|
|
url, err := br.topicURL(topicParam)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(n.body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var actions []string
|
|
|
|
// ntfy authentication
|
|
if br.cfg.Ntfy.Password != "" && br.cfg.Ntfy.User != "" {
|
|
req.SetBasicAuth(br.cfg.Ntfy.User, br.cfg.Ntfy.Password)
|
|
} else if br.cfg.Ntfy.AccessToken != "" {
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", br.cfg.Ntfy.AccessToken))
|
|
}
|
|
|
|
req.Header.Set("X-Title", n.title)
|
|
|
|
if n.priority != "" {
|
|
req.Header.Set("X-Priority", n.priority)
|
|
}
|
|
|
|
if n.icon != "" {
|
|
req.Header.Set("X-Icon", n.icon)
|
|
}
|
|
|
|
if n.tags != "" {
|
|
req.Header.Set("X-Tags", n.tags)
|
|
}
|
|
|
|
if n.emailAddress != "" {
|
|
req.Header.Set("X-Email", n.emailAddress)
|
|
}
|
|
|
|
if n.call != "" {
|
|
req.Header.Set("X-Call", n.call)
|
|
}
|
|
|
|
if n.silenceBody != "" {
|
|
url := br.cfg.BaseURL + "/silences"
|
|
|
|
var authString string
|
|
if br.cfg.User != "" && br.cfg.Password != "" {
|
|
auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", br.cfg.User, br.cfg.Password)))
|
|
authString = fmt.Sprintf(", headers.Authorization=Basic %s", auth)
|
|
}
|
|
|
|
actions = append(actions, fmt.Sprintf("http, Silence, %s, method=POST, body=%s%s", url, n.silenceBody, authString))
|
|
}
|
|
|
|
if br.cfg.Ntfy.GeneratorURLLabel != "" && n.generatorURL != "" {
|
|
actions = append(actions, fmt.Sprintf("view, %s, %s", br.cfg.Ntfy.GeneratorURLLabel, n.generatorURL))
|
|
}
|
|
|
|
nActions := len(actions)
|
|
if nActions > maxNTFYActions {
|
|
br.logger.Warn(fmt.Sprintf("Publish: Too many actions (%d), ntfy only supports up to %d - removing surplus actions.", nActions, maxNTFYActions))
|
|
br.logger.Debug("Action list",
|
|
slog.Any("actions", actions))
|
|
actions = actions[:maxNTFYActions]
|
|
}
|
|
req.Header.Set("Actions", strings.Join(actions, ";"))
|
|
|
|
configFingerprint := br.cfg.Ntfy.CertFingerprint
|
|
if configFingerprint != "" {
|
|
tlsCfg := &tls.Config{}
|
|
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
|
for _, rawCert := range rawCerts {
|
|
hash := sha512.Sum512(rawCert)
|
|
if hex.EncodeToString(hash[:]) == configFingerprint {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if len(rawCerts) == 0 {
|
|
return errors.New("the ntfy server does not offer a certificate")
|
|
}
|
|
|
|
hash := sha512.Sum512(rawCerts[0])
|
|
var expectedFingerprint string
|
|
for i, b := range hash {
|
|
if i != 0 {
|
|
expectedFingerprint += ":"
|
|
}
|
|
expectedFingerprint += fmt.Sprintf("%02X", b)
|
|
}
|
|
return fmt.Errorf("the ntfy certificate fingerprint (%s) is not set in the config", expectedFingerprint)
|
|
}
|
|
|
|
tlsCfg.InsecureSkipVerify = true
|
|
br.client.Transport = &http.Transport{TLSClientConfig: tlsCfg}
|
|
}
|
|
|
|
resp, err := br.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
var ntfyError ntfyError
|
|
if err := json.NewDecoder(resp.Body).Decode(&ntfyError); err != nil {
|
|
br.logger.Debug("Publish: Failed to decode error",
|
|
slog.String("error", err.Error()))
|
|
return fmt.Errorf("ntfy: received status code %d", resp.StatusCode)
|
|
}
|
|
|
|
return fmt.Errorf("ntfy: %s (status code %d)", ntfyError.Error, resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
logger := br.logger.With(slog.String("handler", "/"))
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
|
|
logger.Debug(fmt.Sprintf("Illegal HTTP method: expected %q, got %q", "POST", r.Method))
|
|
return
|
|
}
|
|
|
|
contentType := r.Header.Get("Content-Type")
|
|
if contentType != "application/json" {
|
|
http.Error(w, "Only application/json allowed", http.StatusUnsupportedMediaType)
|
|
logger.Debug(fmt.Sprintf("Illegal content type: %s", contentType))
|
|
return
|
|
}
|
|
|
|
topicParam := r.URL.Query().Get("topic")
|
|
|
|
var event payload
|
|
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
|
http.Error(w, "Failed to parse payload", http.StatusInternalServerError)
|
|
logger.Debug("Failed to decode payload",
|
|
slog.String("error", err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Debug("Received alert",
|
|
slog.Any("payload", event))
|
|
|
|
if br.cfg.AlertMode == config.Single {
|
|
notifications := br.singleAlertNotifications(ctx, &event)
|
|
for _, n := range notifications {
|
|
err := br.publish(n, topicParam)
|
|
if err != nil {
|
|
logger.Error("Failed to publish notification",
|
|
slog.String("error", err.Error()))
|
|
} else {
|
|
if err := br.cache.Set(ctx, n.fingerprint, n.status); err != nil {
|
|
logger.Error("Failed to cache alert",
|
|
slog.String("fingerprint", n.fingerprint),
|
|
slog.String("error", err.Error()))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
notification := br.multiAlertNotification(&event)
|
|
err := br.publish(notification, topicParam)
|
|
if err != nil {
|
|
logger.Error("Failed to publish notification",
|
|
slog.String("error", err.Error()))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (br *bridge) corsMiddleware(handler http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
|
|
handler.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (br *bridge) authMiddleware(handler http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
logger := br.logger.With(slog.String("url", r.URL.String()))
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok {
|
|
logger.Debug("basic auth failure")
|
|
return
|
|
}
|
|
|
|
inputUserHash := sha512.Sum512([]byte(user))
|
|
inputPassHash := sha512.Sum512([]byte(pass))
|
|
configUserHash := sha512.Sum512([]byte(br.cfg.User))
|
|
configPassHash := sha512.Sum512([]byte(br.cfg.Password))
|
|
|
|
validUser := subtle.ConstantTimeCompare(inputUserHash[:], configUserHash[:])
|
|
validPass := subtle.ConstantTimeCompare(inputPassHash[:], configPassHash[:])
|
|
|
|
if validUser != 1 || validPass != 1 {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
logger.Debug("basic auth: wrong user or password")
|
|
return
|
|
}
|
|
|
|
handler.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (br *bridge) runCleanup(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-time.After(br.cfg.Cache.CleanupInterval):
|
|
br.logger.Info("Pruning cache")
|
|
br.cache.Cleanup()
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
cfg, err := config.Read(configPath)
|
|
if err != nil {
|
|
slog.Error("Failed to read config",
|
|
slog.String("error", err.Error()))
|
|
os.Exit(1)
|
|
}
|
|
|
|
var logOutput io.Writer
|
|
if cfg.LogFile != "" {
|
|
f, err := os.OpenFile(cfg.LogFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
|
|
if err != nil {
|
|
slog.Error("Failed to open logfile",
|
|
slog.String("error", err.Error()))
|
|
os.Exit(1)
|
|
}
|
|
defer f.Close()
|
|
logOutput = f
|
|
} else {
|
|
logOutput = os.Stderr
|
|
}
|
|
|
|
logger, err := logging.New(cfg.LogLevel, cfg.LogFormat, logOutput)
|
|
if err != nil {
|
|
slog.Error("Failed to create logger",
|
|
slog.String("error", err.Error()))
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
client := &httpClient{&http.Client{Timeout: time.Second * 3}}
|
|
|
|
c, err := cache.NewCache(ctx, cfg.Cache)
|
|
if err != nil {
|
|
logger.Error("Failed to create cache",
|
|
slog.String("error", err.Error()))
|
|
os.Exit(1)
|
|
}
|
|
bridge := &bridge{cfg: cfg, logger: logger, cache: c, client: client}
|
|
|
|
logger.Info(fmt.Sprintf("Listening on %s, ntfy-alertmanager %s", cfg.HTTPAddress, version))
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", bridge.handleWebhooks)
|
|
mux.HandleFunc("/silences", bridge.handleSilences)
|
|
|
|
httpServer := &http.Server{
|
|
Addr: cfg.HTTPAddress,
|
|
Handler: mux,
|
|
}
|
|
if cfg.User != "" && cfg.Password != "" {
|
|
logger.Info("Enabling HTTP Basic Authentication")
|
|
httpServer.Handler = bridge.authMiddleware(mux)
|
|
}
|
|
|
|
httpServer.Handler = bridge.corsMiddleware(httpServer.Handler)
|
|
|
|
if _, ok := c.(*cache.MemoryCache); ok {
|
|
go bridge.runCleanup(ctx)
|
|
}
|
|
|
|
go func() {
|
|
err = httpServer.ListenAndServe()
|
|
if err != nil && err != http.ErrServerClosed {
|
|
logger.Error("Failed to start HTTP server",
|
|
slog.String("error", err.Error()))
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
stop()
|
|
|
|
httpShutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
err = httpServer.Shutdown(httpShutdownContext)
|
|
if err != nil {
|
|
logger.Error("Failed to shutdown HTTP server",
|
|
slog.String("error", err.Error()))
|
|
}
|
|
}
|
|
|
|
func (br *bridge) topicURL(topic string) (string, error) {
|
|
if topic == "" {
|
|
topic = br.cfg.Ntfy.Topic
|
|
}
|
|
|
|
// Check if the configured topic name already contains the ntfy server
|
|
i := strings.Index(topic, "://")
|
|
if i != -1 {
|
|
return topic, nil
|
|
}
|
|
|
|
if br.cfg.Ntfy.Server == "" {
|
|
return "", errors.New("cannot set topic: no ntfy server set")
|
|
}
|
|
|
|
s, err := url.JoinPath(br.cfg.Ntfy.Server, topic)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return s, nil
|
|
}
|