Compare commits

..

No commits in common. "3bbde04774cdea84a9b9acfdb3224496e11df2ac" and "e1ae4d2e438d2d1ba8408f2df37af10e1cfcb389" have entirely different histories.

19 changed files with 429 additions and 829 deletions

View file

@ -1,28 +1,28 @@
image: archlinux
packages:
- docker
- docker-buildx
- go
- just
- revive
- staticcheck
secrets:
- 8da1d834-8c97-4da0-a7ae-dd755b702691
- 2b44595c-3464-4c2d-a8df-c5dea8e90c4c
sources:
- https://git.xenrox.net/~xenrox/ntfy-alertmanager
tasks:
- test: |
cd ntfy-alertmanager
just test
go test -v ./...
- lint: |
cd ntfy-alertmanager
just lint
go vet ./...
staticcheck ./...
revive ./...
- build: |
cd ntfy-alertmanager
just build
go build
- gofmt: |
cd ntfy-alertmanager
just gofmt
test -z $(gofmt -l .)
- dev-image: |
cd ntfy-alertmanager/docker
if [ "$BUILD_SUBMITTER" != "git.sr.ht" ] || [ "$(git rev-parse master)" != "$(git rev-parse HEAD)" ]
@ -30,10 +30,6 @@ tasks:
complete-build
fi
sudo systemctl start docker
~/.local/bin/docker_login
~/.local/bin/dockerhub_login
docker build -f Dockerfile-dev -t xenrox/ntfy-alertmanager:dev ./..
docker push xenrox/ntfy-alertmanager:dev
docker tag xenrox/ntfy-alertmanager:dev code.xenrox.net/xenrox/ntfy-alertmanager:dev
docker push code.xenrox.net/xenrox/ntfy-alertmanager:dev
docker tag xenrox/ntfy-alertmanager:dev codeberg.org/xenrox/ntfy-alertmanager:dev
docker push codeberg.org/xenrox/ntfy-alertmanager:dev

View file

@ -1,49 +0,0 @@
version := "0.4.0"
default:
@just --choose
test:
go test -v ./...
lint:
go vet ./...
staticcheck ./...
revive ./...
gofmt:
gofmt -l .
@test -z $(gofmt -l .)
build:
go build
release-docker:
docker build -t xenrox/ntfy-alertmanager:latest docker
docker tag xenrox/ntfy-alertmanager:latest xenrox/ntfy-alertmanager:{{version}}
docker push xenrox/ntfy-alertmanager:latest
docker push xenrox/ntfy-alertmanager:{{version}}
docker tag xenrox/ntfy-alertmanager:latest code.xenrox.net/xenrox/ntfy-alertmanager:latest
docker tag xenrox/ntfy-alertmanager:latest code.xenrox.net/xenrox/ntfy-alertmanager:{{version}}
docker push code.xenrox.net/xenrox/ntfy-alertmanager:latest
docker push code.xenrox.net/xenrox/ntfy-alertmanager:{{version}}
docker tag xenrox/ntfy-alertmanager:latest codeberg.org/xenrox/ntfy-alertmanager:latest
docker tag xenrox/ntfy-alertmanager:latest codeberg.org/xenrox/ntfy-alertmanager:{{version}}
docker push codeberg.org/xenrox/ntfy-alertmanager:latest
docker push codeberg.org/xenrox/ntfy-alertmanager:{{version}}
upgrade-deps:
go get -u ./...
go mod tidy
@run:
go run . --config ./devconfig.scfg
@curl:
curl --user "user:pass" -X 'POST' \
'127.0.0.1:8080' \
-H 'Content-Type: application/json' \
-d @contrib/test_payload.json

View file

@ -6,7 +6,7 @@ A bridge between ntfy and Alertmanager.
## Installation
Simply use go build or the [docker image] ([Codeberg image mirror]) with [docker compose file].
Simply use go build or the [docker image] with [docker-compose file].
`ntfy-alertmanager:latest` is built from the latest tagged release while
`ntfy-alertmanager:dev` is built from the master branch.
On Arch Linux you can install the [aur package] as well.
@ -19,8 +19,8 @@ of this file is [scfg] and there is an [example configuration file] in this repo
Furthermore you can take a look at [my deployment].
ntfy-alertmanager has support for setting ntfy [priority], [tags], [icon], [action buttons]
(which can be used to e.g. create an Alertmanager silence or open the alert's Prometheus URL), [email notifications] and [phone calls].
Define a decreasing order of labels in the config file and map those labels to tags, priority, an icon, an email address or an alternative ntfy topic.
(which can be used to create an Alertmanager silence), [email notifications] and [phone calls].
Define a decreasing order of labels in the config file and map those labels to tags, priority, an icon or an email address.
- For priority and icon the first found value will be chosen. Settings for "resolved" alerts will take precedence.
- Tags are added together.
@ -42,7 +42,6 @@ receivers:
Report bugs on the [issue tracker], send patches/ask questions on the [mailing list]
or write to me directly on matrix [@xenrox:xenrox.net].
There is a [mirror on Codeberg] as well, where you can create issues or open pull requests.
[ntfy-alertmanager]: https://hub.xenrox.net/~xenrox/ntfy-alertmanager/
[scfg]: https://git.sr.ht/~emersion/scfg
@ -56,9 +55,7 @@ There is a [mirror on Codeberg] as well, where you can create issues or open pul
[mailing list]: https://lists.xenrox.net/~xenrox/public-inbox
[@xenrox:xenrox.net]: https://matrix.to/#/@xenrox:xenrox.net
[docker image]: https://hub.docker.com/r/xenrox/ntfy-alertmanager
[Codeberg image mirror]: https://codeberg.org/xenrox/-/packages/container/ntfy-alertmanager
[docker compose file]: https://git.xenrox.net/~xenrox/ntfy-alertmanager/tree/master/item/docker/compose.yaml
[docker-compose file]: https://git.xenrox.net/~xenrox/ntfy-alertmanager/tree/master/item/docker/docker-compose.yml
[example configuration file]: https://git.xenrox.net/~xenrox/ntfy-alertmanager/tree/master/item/config.scfg
[my deployment]: https://git.xenrox.net/~xenrox/ansible/tree/master/item/roles/alertmanager/templates/ntfy-alertmanager.j2
[aur package]: https://aur.archlinux.org/packages/ntfy-alertmanager
[mirror on Codeberg]: https://codeberg.org/xenrox/ntfy-alertmanager

9
cache/cache.go vendored
View file

@ -2,7 +2,6 @@
package cache
import (
"context"
"fmt"
"strings"
@ -11,18 +10,18 @@ import (
// Cache is the interface that describes a cache for ntfy-alertmanager.
type Cache interface {
Set(ctx context.Context, fingerprint string, status string) error
Contains(ctx context.Context, fingerprint string, status string) (bool, error)
Set(fingerprint string, status string) error
Contains(fingerprint string, status string) (bool, error)
Cleanup()
}
// NewCache reads the cache configuration cfg and creates the cache.
func NewCache(ctx context.Context, cfg config.CacheConfig) (Cache, error) {
func NewCache(cfg config.CacheConfig) (Cache, error) {
switch strings.ToLower(cfg.Type) {
case "memory":
return NewMemoryCache(cfg.Duration), nil
case "redis":
return NewRedisCache(ctx, cfg.RedisURL, cfg.Duration)
return NewRedisCache(cfg.RedisURL, cfg.Duration)
case "disabled":
return NewDisabledCache()
default:

6
cache/disabled.go vendored
View file

@ -1,7 +1,5 @@
package cache
import "context"
// DisabledCache is the disabled cache.
type DisabledCache struct{}
@ -12,12 +10,12 @@ func NewDisabledCache() (Cache, error) {
}
// Set is an empty function to implement the interface.
func (c *DisabledCache) Set(_ context.Context, _ string, _ string) error {
func (c *DisabledCache) Set(_ string, _ string) error {
return nil
}
// Contains is an empty function to implement the interface.
func (c *DisabledCache) Contains(_ context.Context, _ string, _ string) (bool, error) {
func (c *DisabledCache) Contains(_ string, _ string) (bool, error) {
return false, nil
}

5
cache/memory.go vendored
View file

@ -1,7 +1,6 @@
package cache
import (
"context"
"sync"
"time"
)
@ -28,7 +27,7 @@ func NewMemoryCache(d time.Duration) Cache {
}
// Set saves an alert in the cache.
func (c *MemoryCache) Set(_ context.Context, fingerprint string, status string) error {
func (c *MemoryCache) Set(fingerprint string, status string) error {
c.mu.Lock()
defer c.mu.Unlock()
alert := new(cachedAlert)
@ -41,7 +40,7 @@ func (c *MemoryCache) Set(_ context.Context, fingerprint string, status string)
// Contains checks if an alert with a given fingerprint is in the cache
// and if the status matches.
func (c *MemoryCache) Contains(_ context.Context, fingerprint string, status string) (bool, error) {
func (c *MemoryCache) Contains(fingerprint string, status string) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
alert, ok := c.alerts[fingerprint]

12
cache/redis.go vendored
View file

@ -16,7 +16,7 @@ type RedisCache struct {
}
// NewRedisCache creates a new redis cache/client.
func NewRedisCache(ctx context.Context, redisURL string, d time.Duration) (Cache, error) {
func NewRedisCache(redisURL string, d time.Duration) (Cache, error) {
c := new(RedisCache)
ropts, err := redis.ParseURL(redisURL)
if err != nil {
@ -24,7 +24,7 @@ func NewRedisCache(ctx context.Context, redisURL string, d time.Duration) (Cache
}
rdb := redis.NewClient(ropts)
ctx, cancel := context.WithTimeout(ctx, redisTimeout)
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
defer cancel()
err = rdb.Ping(ctx).Err()
@ -38,8 +38,8 @@ func NewRedisCache(ctx context.Context, redisURL string, d time.Duration) (Cache
}
// Set saves an alert in the cache.
func (c *RedisCache) Set(ctx context.Context, fingerprint string, status string) error {
ctx, cancel := context.WithTimeout(ctx, redisTimeout)
func (c *RedisCache) Set(fingerprint string, status string) error {
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
defer cancel()
return c.client.SetEx(ctx, fingerprint, status, c.duration).Err()
@ -47,8 +47,8 @@ func (c *RedisCache) Set(ctx context.Context, fingerprint string, status string)
// Contains checks if an alert with a given fingerprint is in the cache
// and if the status matches.
func (c *RedisCache) Contains(ctx context.Context, fingerprint string, status string) (bool, error) {
ctx, cancel := context.WithTimeout(ctx, redisTimeout)
func (c *RedisCache) Contains(fingerprint string, status string) (bool, error) {
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
defer cancel()
val, err := c.client.Get(ctx, fingerprint).Result()

View file

@ -1,36 +1,17 @@
# Absolute path to another scfg configuration file which will be included.
# This directive can be specified multiple times in the main configuration,
# but only the last occurrence of a setting will be used. Settings from
# the main configuration will take precedence.
# Default: unset
include /etc/ntfy-alertmanager/ntfy.scfg
# Public facing base URL of the service (e.g. https://ntfy-alertmanager.example.com)
# Public facing base URL of the service (e.g. https://ntfy-alertmanager.xenrox.net)
# This setting is required for the "Silence" feature.
# Default: ""
base-url https://ntfy-alertmanager.example.com
base-url https://ntfy-alertmanager.xenrox.net
# http listen address
# Default: 127.0.0.1:8080
http-address :8080
# Log level
# Options: debug, info, warning, error
# Default: info
# Log level (either debug, info, warning, error)
log-level info
# Log format
# Options: text, json
# Default: text
# Log format (either text or json)
log-format text
# Write logs to this file. If unset, the logs will be written to stderr.
# Default: ""
log-file /var/log/ntfy-alertmanager.log
# When multiple alerts are grouped together by Alertmanager, they can either be sent
# each on their own (single mode) or be kept together (multi mode)
# Options: single, multi
# Default: multi
# each on their own (single mode) or be kept together (multi mode) (either single or multi; default is multi)
alert-mode single
# Optionally protect with HTTP basic authentication
# Default: ""
user webhookUser
# Default: ""
password webhookPass
labels {
@ -39,9 +20,9 @@ labels {
severity "critical" {
priority 5
tags "rotating_light"
icon "https://example.com/critical.png"
icon "https://foo.com/critical.png"
# Forward messages which severity "critical" to the specified email address.
email-address foo@example.com
email-address foo@bar.com
# Call the specified number. Use `yes` to pick the first of your verified numbers.
call yes
}
@ -51,7 +32,6 @@ labels {
}
instance "example.com" {
topic homeserver
tags "computer,example"
}
}
@ -59,47 +39,29 @@ labels {
# Settings for resolved alerts
resolved {
tags "resolved,partying_face"
icon "https://example.com/resolved.png"
icon "https://foo.com/resolved.png"
priority 1
}
ntfy {
# URL of the ntfy server.
# Default: ""
server https://ntfy.sh
# Name of the ntfy topic. For backwards compatibility you can specify the full URL of the
# topic (e.g. https://ntfy.sh/alertmanager-alerts) and the server will be parsed from it.
# Furthermore the topic name can be optionally set by using URL parameters with the webhook
# endpoint: https://ntfy-alertmanager.example.com/?topic=foobar
# This setting is required.
# Default: ""
topic alertmanager-alerts
# URL of the ntfy topic - required
topic https://ntfy.sh/alertmanager-alerts
# ntfy authentication via Basic Auth (https://docs.ntfy.sh/publish/#username-password)
# Default: ""
user user
# Default: ""
password pass
# ntfy authentication via access tokens (https://docs.ntfy.sh/publish/#access-tokens)
# Either access-token or a user/password combination can be used - not both.
# Default: ""
access-token foobar
# When using (self signed) certificates that cannot be verified, you can instead specify
# the SHA512 fingerprint.
# openssl can be used to obtain it:
# openssl s_client -connect HOST:PORT | openssl x509 -fingerprint -sha512 -noout
# For convenience ntfy-alertmanager will convert the certificate to lower case and remove all colons.
# Default: ""
certificate-fingerprint 13:6D:2B:88:9C:57:36:D0:81:B4:B2:9C:79:09:27:62:92:CF:B8:6A:6B:D3:AD:46:35:CB:70:17:EB:99:6E:28:08:2A:B8:C6:79:4B:F6:2E:81:79:41:98:1D:53:C8:07:B3:5C:24:5F:B1:8E:B6:FB:66:B5:DD:B4:D0:5C:29:91
# Forward all messages to the specified email address.
# Default: ""
email-address foo@example.com
email-address foo@bar.com
# Call the specified number for all alerts. Use `yes` to pick the first of your verified numbers.
# Default: ""
call +123456789
# Add a button that will open the alert's generator/Prometheus URL with the following label.
# This only works for the "single" alert-mode.
# Default: ""
generator-url-label source
}
alertmanager {
@ -111,36 +73,28 @@ alertmanager {
# When alert-mode is set to "single" all alert labels will be used to create the silence.
# When it is "multi" common labels between all the alerts will be used. WARNING: This
# could silence unwanted alerts.
# Default: ""
silence-duration 24h
# Basic authentication (https://prometheus.io/docs/alerting/latest/https/)
# Default: ""
user user
# Default: ""
password pass
# By default the Alertmanager URL gets parsed from the webhook. In case that
# Alertmanger is not reachable under that URL, it can be overwritten here.
url https://alertmanager.example.com
url https://alertmanager.xenrox.net
}
# When the alert-mode is set to single, ntfy-alertmanager will cache each single alert
# to avoid sending recurrences.
cache {
# The type of cache that will be used
# Options: disabled, memory, redis
# Default: disabled
# The type of cache that will be used (either disabled, memory or redis; default is disabled).
type memory
# How long messages stay in the cache for
# Default: 24h
duration 24h
# Memory cache settings
# Interval in which the cache is cleaned up
# Default: 1h
cleanup-interval 1h
# Redis cache settings
# URL to connect to redis (default: redis://localhost:6379)
# Default: redis://localhost:6379
redis-url redis://user:password@localhost:6789/3
}

View file

@ -25,7 +25,6 @@ type Config struct {
HTTPAddress string
LogLevel string
LogFormat string
LogFile string
AlertMode AlertMode
User string
Password string
@ -37,7 +36,6 @@ type Config struct {
}
type ntfyConfig struct {
Server string
Topic string
User string
Password string
@ -45,7 +43,6 @@ type ntfyConfig struct {
CertFingerprint string
EmailAddress string
Call string
GeneratorURLLabel string
}
type labels struct {
@ -59,7 +56,6 @@ type labelConfig struct {
Icon string
EmailAddress string
Call string
Topic string
}
// CacheConfig is the configuration of the cache.
@ -86,338 +82,8 @@ type resolvedConfig struct {
Priority string
}
func parseBlock(block scfg.Block, config *Config) error {
d := block.Get("log-level")
if d != nil {
if err := d.ParseParams(&config.LogLevel); err != nil {
return err
}
}
d = block.Get("log-format")
if d != nil {
if err := d.ParseParams(&config.LogFormat); err != nil {
return err
}
}
d = block.Get("log-file")
if d != nil {
if err := d.ParseParams(&config.LogFile); err != nil {
return err
}
}
d = block.Get("http-address")
if d != nil {
if err := d.ParseParams(&config.HTTPAddress); err != nil {
return err
}
}
d = block.Get("base-url")
if d != nil {
if err := d.ParseParams(&config.BaseURL); err != nil {
return err
}
}
d = block.Get("alert-mode")
if d != nil {
var mode string
if err := d.ParseParams(&mode); err != nil {
return err
}
switch strings.ToLower(mode) {
case "single":
config.AlertMode = Single
case "multi":
config.AlertMode = Multi
default:
return fmt.Errorf("%q directive: illegal mode %q", d.Name, mode)
}
}
d = block.Get("user")
if d != nil {
if err := d.ParseParams(&config.User); err != nil {
return err
}
}
d = block.Get("password")
if d != nil {
if err := d.ParseParams(&config.Password); err != nil {
return err
}
}
labelsDir := block.Get("labels")
if labelsDir != nil {
d = labelsDir.Children.Get("order")
if d != nil {
var order string
if err := d.ParseParams(&order); err != nil {
return err
}
config.Labels.Order = strings.Split(order, ",")
}
labels := make(map[string]labelConfig)
for _, labelName := range config.Labels.Order {
for _, labelDir := range labelsDir.Children.GetAll(labelName) {
labelConfig := new(labelConfig)
var name string
if err := labelDir.ParseParams(&name); err != nil {
return err
}
d = labelDir.Children.Get("priority")
if d != nil {
if err := d.ParseParams(&labelConfig.Priority); err != nil {
return err
}
}
d = labelDir.Children.Get("tags")
if d != nil {
var tags string
if err := d.ParseParams(&tags); err != nil {
return err
}
labelConfig.Tags = strings.Split(tags, ",")
}
d = labelDir.Children.Get("icon")
if d != nil {
if err := d.ParseParams(&labelConfig.Icon); err != nil {
return err
}
}
d = labelDir.Children.Get("email-address")
if d != nil {
if err := d.ParseParams(&labelConfig.EmailAddress); err != nil {
return err
}
}
d = labelDir.Children.Get("call")
if d != nil {
if err := d.ParseParams(&labelConfig.Call); err != nil {
return err
}
}
d = labelDir.Children.Get("topic")
if d != nil {
if err := d.ParseParams(&labelConfig.Topic); err != nil {
return err
}
}
labels[fmt.Sprintf("%s:%s", labelName, name)] = *labelConfig
}
}
config.Labels.Label = labels
}
ntfyDir := block.Get("ntfy")
if ntfyDir != nil {
d = ntfyDir.Children.Get("server")
if d != nil {
if err := d.ParseParams(&config.Ntfy.Server); err != nil {
return err
}
}
d = ntfyDir.Children.Get("topic")
if d != nil {
if err := d.ParseParams(&config.Ntfy.Topic); err != nil {
return err
}
}
d = ntfyDir.Children.Get("user")
if d != nil {
if err := d.ParseParams(&config.Ntfy.User); err != nil {
return err
}
}
d = ntfyDir.Children.Get("password")
if d != nil {
if err := d.ParseParams(&config.Ntfy.Password); err != nil {
return err
}
}
d = ntfyDir.Children.Get("access-token")
if d != nil {
if err := d.ParseParams(&config.Ntfy.AccessToken); err != nil {
return err
}
}
d = ntfyDir.Children.Get("certificate-fingerprint")
if d != nil {
if err := d.ParseParams(&config.Ntfy.CertFingerprint); err != nil {
return err
}
// hex.EncodeToString outputs a lower case string
config.Ntfy.CertFingerprint = strings.ToLower(strings.ReplaceAll(config.Ntfy.CertFingerprint, ":", ""))
}
d = ntfyDir.Children.Get("email-address")
if d != nil {
if err := d.ParseParams(&config.Ntfy.EmailAddress); err != nil {
return err
}
}
d = ntfyDir.Children.Get("call")
if d != nil {
if err := d.ParseParams(&config.Ntfy.Call); err != nil {
return err
}
}
d = ntfyDir.Children.Get("generator-url-label")
if d != nil {
if err := d.ParseParams(&config.Ntfy.GeneratorURLLabel); err != nil {
return err
}
}
}
cacheDir := block.Get("cache")
if cacheDir != nil {
d = cacheDir.Children.Get("type")
if d != nil {
if err := d.ParseParams(&config.Cache.Type); err != nil {
return err
}
}
var durationString string
d = cacheDir.Children.Get("duration")
if d != nil {
if err := d.ParseParams(&durationString); err != nil {
return err
}
duration, err := time.ParseDuration(durationString)
if err != nil {
return err
}
config.Cache.Duration = duration
}
// memory
var cleanupIntervalString string
d = cacheDir.Children.Get("cleanup-interval")
if d != nil {
if err := d.ParseParams(&cleanupIntervalString); err != nil {
return err
}
interval, err := time.ParseDuration(cleanupIntervalString)
if err != nil {
return err
}
config.Cache.CleanupInterval = interval
}
// redis
d = cacheDir.Children.Get("redis-url")
if d != nil {
if err := d.ParseParams(&config.Cache.RedisURL); err != nil {
return err
}
}
}
amDir := block.Get("alertmanager")
if amDir != nil {
var durationString string
d = amDir.Children.Get("silence-duration")
if d != nil {
if err := d.ParseParams(&durationString); err != nil {
return err
}
duration, err := time.ParseDuration(durationString)
if err != nil {
return err
}
config.Am.SilenceDuration = duration
}
d = amDir.Children.Get("user")
if d != nil {
if err := d.ParseParams(&config.Am.User); err != nil {
return err
}
}
d = amDir.Children.Get("password")
if d != nil {
if err := d.ParseParams(&config.Am.Password); err != nil {
return err
}
}
d = amDir.Children.Get("url")
if d != nil {
if err := d.ParseParams(&config.Am.URL); err != nil {
return err
}
}
}
resolvedDir := block.Get("resolved")
if resolvedDir != nil {
d = resolvedDir.Children.Get("tags")
if d != nil {
var tags string
if err := d.ParseParams(&tags); err != nil {
return err
}
config.Resolved.Tags = strings.Split(tags, ",")
}
d = resolvedDir.Children.Get("icon")
if d != nil {
if err := d.ParseParams(&config.Resolved.Icon); err != nil {
return err
}
}
d = resolvedDir.Children.Get("priority")
if d != nil {
if err := d.ParseParams(&config.Resolved.Priority); err != nil {
return err
}
}
}
return nil
}
// Read reads an scfg formatted file and returns the configuration struct.
func Read(path string) (*Config, error) {
// ReadConfig reads an scfg formatted file and returns the configuration struct.
func ReadConfig(path string) (*Config, error) {
cfg, err := scfg.Load(path)
if err != nil {
return nil, err
@ -437,51 +103,327 @@ func Read(path string) (*Config, error) {
// redis
config.Cache.RedisURL = "redis://localhost:6379"
includeDirs := cfg.GetAll("include")
for _, d := range includeDirs {
var includePath string
if err := d.ParseParams(&includePath); err != nil {
d := cfg.Get("log-level")
if d != nil {
if err := d.ParseParams(&config.LogLevel); err != nil {
return nil, err
}
}
d = cfg.Get("log-format")
if d != nil {
if err := d.ParseParams(&config.LogFormat); err != nil {
return nil, err
}
}
d = cfg.Get("http-address")
if d != nil {
if err := d.ParseParams(&config.HTTPAddress); err != nil {
return nil, err
}
}
d = cfg.Get("base-url")
if d != nil {
if err := d.ParseParams(&config.BaseURL); err != nil {
return nil, err
}
}
d = cfg.Get("alert-mode")
if d != nil {
var mode string
if err := d.ParseParams(&mode); err != nil {
return nil, err
}
block, err := scfg.Load(includePath)
if err != nil {
return nil, fmt.Errorf("cannot load included config file %q: %v", includePath, err)
}
switch strings.ToLower(mode) {
case "single":
config.AlertMode = Single
if err := parseBlock(block, config); err != nil {
return nil, fmt.Errorf("cannot parse included config file %q: %v", includePath, err)
case "multi":
config.AlertMode = Multi
default:
return nil, fmt.Errorf("%q directive: illegal mode %q", d.Name, mode)
}
}
err = parseBlock(cfg, config)
if err != nil {
d = cfg.Get("user")
if d != nil {
if err := d.ParseParams(&config.User); err != nil {
return nil, err
}
}
d = cfg.Get("password")
if d != nil {
if err := d.ParseParams(&config.Password); err != nil {
return nil, err
}
}
// Check settings
if (config.Password != "" && config.User == "") ||
(config.Password == "" && config.User != "") {
return nil, errors.New("user and password have to be set together")
}
if config.Ntfy.Topic == "" {
labelsDir := cfg.Get("labels")
if labelsDir != nil {
d = labelsDir.Children.Get("order")
if d != nil {
var order string
if err := d.ParseParams(&order); err != nil {
return nil, err
}
config.Labels.Order = strings.Split(order, ",")
}
labels := make(map[string]labelConfig)
for _, labelName := range config.Labels.Order {
for _, labelDir := range labelsDir.Children.GetAll(labelName) {
labelConfig := new(labelConfig)
var name string
if err := labelDir.ParseParams(&name); err != nil {
return nil, err
}
d = labelDir.Children.Get("priority")
if d != nil {
if err := d.ParseParams(&labelConfig.Priority); err != nil {
return nil, err
}
}
d = labelDir.Children.Get("tags")
if d != nil {
var tags string
if err := d.ParseParams(&tags); err != nil {
return nil, err
}
labelConfig.Tags = strings.Split(tags, ",")
}
d = labelDir.Children.Get("icon")
if d != nil {
if err := d.ParseParams(&labelConfig.Icon); err != nil {
return nil, err
}
}
d = labelDir.Children.Get("email-address")
if d != nil {
if err := d.ParseParams(&labelConfig.EmailAddress); err != nil {
return nil, err
}
}
d = labelDir.Children.Get("call")
if d != nil {
if err := d.ParseParams(&labelConfig.Call); err != nil {
return nil, err
}
}
labels[fmt.Sprintf("%s:%s", labelName, name)] = *labelConfig
}
}
config.Labels.Label = labels
}
ntfyDir := cfg.Get("ntfy")
if ntfyDir == nil {
return nil, fmt.Errorf("%q directive missing", "ntfy")
}
d = ntfyDir.Children.Get("topic")
if d == nil {
return nil, fmt.Errorf("%q missing from %q directive", "topic", "ntfy")
}
if err := d.ParseParams(&config.Ntfy.Topic); err != nil {
return nil, err
}
d = ntfyDir.Children.Get("user")
if d != nil {
if err := d.ParseParams(&config.Ntfy.User); err != nil {
return nil, err
}
}
d = ntfyDir.Children.Get("password")
if d != nil {
if err := d.ParseParams(&config.Ntfy.Password); err != nil {
return nil, err
}
}
if (config.Ntfy.Password != "" && config.Ntfy.User == "") ||
(config.Ntfy.Password == "" && config.Ntfy.User != "") {
return nil, errors.New("ntfy: user and password have to be set together")
}
d = ntfyDir.Children.Get("access-token")
if d != nil {
if err := d.ParseParams(&config.Ntfy.AccessToken); err != nil {
return nil, err
}
}
if config.Ntfy.User != "" && config.Ntfy.AccessToken != "" {
return nil, errors.New("ntfy: cannot use both an access-token and a user/password at the same time")
}
d = ntfyDir.Children.Get("certificate-fingerprint")
if d != nil {
if err := d.ParseParams(&config.Ntfy.CertFingerprint); err != nil {
return nil, err
}
// hex.EncodeToString outputs a lower case string
config.Ntfy.CertFingerprint = strings.ToLower(strings.ReplaceAll(config.Ntfy.CertFingerprint, ":", ""))
}
d = ntfyDir.Children.Get("email-address")
if d != nil {
if err := d.ParseParams(&config.Ntfy.EmailAddress); err != nil {
return nil, err
}
}
d = ntfyDir.Children.Get("call")
if d != nil {
if err := d.ParseParams(&config.Ntfy.Call); err != nil {
return nil, err
}
}
cacheDir := cfg.Get("cache")
if cacheDir != nil {
d = cacheDir.Children.Get("type")
if d != nil {
if err := d.ParseParams(&config.Cache.Type); err != nil {
return nil, err
}
}
var durationString string
d = cacheDir.Children.Get("duration")
if d != nil {
if err := d.ParseParams(&durationString); err != nil {
return nil, err
}
duration, err := time.ParseDuration(durationString)
if err != nil {
return nil, err
}
config.Cache.Duration = duration
}
// memory
var cleanupIntervalString string
d = cacheDir.Children.Get("cleanup-interval")
if d != nil {
if err := d.ParseParams(&cleanupIntervalString); err != nil {
return nil, err
}
interval, err := time.ParseDuration(cleanupIntervalString)
if err != nil {
return nil, err
}
config.Cache.CleanupInterval = interval
}
// redis
d = cacheDir.Children.Get("redis-url")
if d != nil {
if err := d.ParseParams(&config.Cache.RedisURL); err != nil {
return nil, err
}
}
}
amDir := cfg.Get("alertmanager")
if amDir != nil {
var durationString string
d = amDir.Children.Get("silence-duration")
if d != nil {
if err := d.ParseParams(&durationString); err != nil {
return nil, err
}
duration, err := time.ParseDuration(durationString)
if err != nil {
return nil, err
}
config.Am.SilenceDuration = duration
}
d = amDir.Children.Get("user")
if d != nil {
if err := d.ParseParams(&config.Am.User); err != nil {
return nil, err
}
}
d = amDir.Children.Get("password")
if d != nil {
if err := d.ParseParams(&config.Am.Password); err != nil {
return nil, err
}
}
if (config.Am.Password != "" && config.Am.User == "") ||
(config.Am.Password == "" && config.Am.User != "") {
return nil, errors.New("alertmanager: user and password have to be set together")
}
d = amDir.Children.Get("url")
if d != nil {
if err := d.ParseParams(&config.Am.URL); err != nil {
return nil, err
}
}
}
resolvedDir := cfg.Get("resolved")
if resolvedDir != nil {
d = resolvedDir.Children.Get("tags")
if d != nil {
var tags string
if err := d.ParseParams(&tags); err != nil {
return nil, err
}
config.Resolved.Tags = strings.Split(tags, ",")
}
d = resolvedDir.Children.Get("icon")
if d != nil {
if err := d.ParseParams(&config.Resolved.Icon); err != nil {
return nil, err
}
}
d = resolvedDir.Children.Get("priority")
if d != nil {
if err := d.ParseParams(&config.Resolved.Priority); err != nil {
return nil, err
}
}
}
return config, nil
}

View file

@ -10,11 +10,10 @@ import (
func TestReadConfig(t *testing.T) {
configContent := `
base-url https://ntfy-alertmanager.example.com
base-url https://ntfy-alertmanager.xenrox.net
http-address :8080
log-level info
log-format json
log-file /var/log/ntfy-alertmanager.log
alert-mode multi
user webhookUser
password webhookPass
@ -25,8 +24,8 @@ labels {
severity "critical" {
priority 5
tags "rotating_light"
icon "https://example.com/critical.png"
email-address foo@example.com
icon "https://foo.com/critical.png"
email-address foo@bar.com
call yes
}
@ -36,30 +35,27 @@ labels {
instance "example.com" {
tags "computer,example"
topic homeserver
}
}
resolved {
tags "resolved,partying_face"
icon "https://example.com/resolved.png"
icon "https://foo.com/resolved.png"
priority 1
}
ntfy {
server https://ntfy.sh
topic https://ntfy.sh/alertmanager-alerts
certificate-fingerprint 13:6D:2B:88:9C:57:36:D0:81:B4:B2:9C:79:09:27:62:92:CF:B8:6A:6B:D3:AD:46:35:CB:70:17:EB:99:6E:28:08:2A:B8:C6:79:4B:F6:2E:81:79:41:98:1D:53:C8:07:B3:5C:24:5F:B1:8E:B6:FB:66:B5:DD:B4:D0:5C:29:91
user user
password pass
generator-url-label source
}
alertmanager {
silence-duration 24h
user user
password pass
url https://alertmanager.example.com
url https://alertmanager.xenrox.net
}
cache {
@ -70,35 +66,30 @@ cache {
`
expectedCfg := &Config{
BaseURL: "https://ntfy-alertmanager.example.com",
BaseURL: "https://ntfy-alertmanager.xenrox.net",
HTTPAddress: ":8080",
LogLevel: "info",
LogFormat: "json",
LogFile: "/var/log/ntfy-alertmanager.log",
AlertMode: Multi,
User: "webhookUser",
Password: "webhookPass",
Ntfy: ntfyConfig{
Server: "https://ntfy.sh",
Topic: "https://ntfy.sh/alertmanager-alerts",
User: "user",
Password: "pass",
CertFingerprint: "136d2b889c5736d081b4b29c7909276292cfb86a6bd3ad4635cb7017eb996e28082ab8c6794bf62e817941981d53c807b35c245fb18eb6fb66b5ddb4d05c2991",
GeneratorURLLabel: "source",
},
Labels: labels{Order: []string{"severity", "instance"},
Label: map[string]labelConfig{
"severity:critical": {
Priority: "5",
Tags: []string{"rotating_light"},
Icon: "https://example.com/critical.png",
EmailAddress: "foo@example.com",
Icon: "https://foo.com/critical.png",
EmailAddress: "foo@bar.com",
Call: "yes",
},
"severity:info": {Priority: "1"},
"instance:example.com": {
Tags: []string{"computer", "example"},
Topic: "homeserver"},
"instance:example.com": {Tags: []string{"computer", "example"}},
},
},
Cache: CacheConfig{
@ -111,11 +102,11 @@ cache {
SilenceDuration: time.Hour * 24,
User: "user",
Password: "pass",
URL: "https://alertmanager.example.com",
URL: "https://alertmanager.xenrox.net",
},
Resolved: resolvedConfig{
Tags: []string{"resolved", "partying_face"},
Icon: "https://example.com/resolved.png",
Icon: "https://foo.com/resolved.png",
Priority: "1",
},
}
@ -126,7 +117,7 @@ cache {
t.Errorf("failed to write config file: %v", err)
}
cfg, err := Read(configPath)
cfg, err := ReadConfig(configPath)
if err != nil {
t.Errorf("failed to read config file: %v", err)
}

View file

@ -1,80 +0,0 @@
{
"receiver": "test-receiver",
"status": "firing",
"alerts": [
{
"status": "firing",
"labels": {
"alertname": "systemd_unit_failed",
"instance": "example.com",
"job": "node_exporter",
"name": "example.service",
"severity": "critical",
"state": "failed",
"type": "oneshot"
},
"annotations": {
"description": "Instance example.com: Service example.service failed",
"summary": "Systemd unit failed"
},
"startsAt": "2022-10-09T08:31:47.929Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=node_systemd_unit_state%7Bstate%3D%22failed%22%7D+%3E+0\u0026g0.tab=1",
"fingerprint": "6f1c1a905a91802b"
},
{
"status": "firing",
"labels": {
"alertname": "systemd_unit_failed",
"instance": "example.com",
"job": "node_exporter",
"name": "bar.service",
"severity": "critical",
"state": "failed",
"type": "oneshot"
},
"annotations": {
"description": "Instance example.com: Service bar.service failed",
"summary": "Systemd unit failed"
},
"startsAt": "2022-10-06T12:48:47.929Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=node_systemd_unit_state%7Bstate%3D%22failed%22%7D+%3E+0\u0026g0.tab=1",
"fingerprint": "0b17134bedc094da"
},
{
"status": "firing",
"labels": {
"alertname": "systemd_unit_failed",
"instance": "example.com",
"job": "node_exporter",
"name": "foo.service",
"severity": "critical",
"state": "failed",
"type": "oneshot"
},
"annotations": {
"description": "Instance example.com: Service foo.service failed",
"summary": "Systemd unit failed"
},
"startsAt": "2022-10-09T00:03:47.929Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=node_systemd_unit_state%7Bstate%3D%22failed%22%7D+%3E+0\u0026g0.tab=1",
"fingerprint": "aaaabf78228dbbc6"
}
],
"groupLabels": { "instance": "example.com", "severity": "critical" },
"commonLabels": {
"alertname": "systemd_unit_failed",
"instance": "example.com",
"job": "node_exporter",
"severity": "critical",
"state": "failed",
"type": "oneshot"
},
"commonAnnotations": { "summary": "Systemd unit failed" },
"externalURL": "http://example.com:9093",
"version": "4",
"groupKey": "{}:{instance=\"example.com\", severity=\"critical\"}",
"truncatedAlerts": 0
}

View file

@ -1,80 +0,0 @@
{
"receiver": "test-receiver",
"status": "firing",
"alerts": [
{
"status": "resolved",
"labels": {
"alertname": "systemd_unit_failed",
"instance": "example.com",
"job": "node_exporter",
"name": "example.service",
"severity": "critical",
"state": "failed",
"type": "oneshot"
},
"annotations": {
"description": "Instance example.com: Service example.service failed",
"summary": "Systemd unit failed"
},
"startsAt": "2022-10-09T08:31:47.929Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=node_systemd_unit_state%7Bstate%3D%22failed%22%7D+%3E+0\u0026g0.tab=1",
"fingerprint": "6f1c1a905a91802b"
},
{
"status": "firing",
"labels": {
"alertname": "systemd_unit_failed",
"instance": "example.com",
"job": "node_exporter",
"name": "bar.service",
"severity": "critical",
"state": "failed",
"type": "oneshot"
},
"annotations": {
"description": "Instance example.com: Service bar.service failed",
"summary": "Systemd unit failed"
},
"startsAt": "2022-10-06T12:48:47.929Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=node_systemd_unit_state%7Bstate%3D%22failed%22%7D+%3E+0\u0026g0.tab=1",
"fingerprint": "0b17134bedc094da"
},
{
"status": "firing",
"labels": {
"alertname": "systemd_unit_failed",
"instance": "example.com",
"job": "node_exporter",
"name": "foo.service",
"severity": "critical",
"state": "failed",
"type": "oneshot"
},
"annotations": {
"description": "Instance example.com: Service foo.service failed",
"summary": "Systemd unit failed"
},
"startsAt": "2022-10-09T00:03:47.929Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=node_systemd_unit_state%7Bstate%3D%22failed%22%7D+%3E+0\u0026g0.tab=1",
"fingerprint": "aaaabf78228dbbc6"
}
],
"groupLabels": { "instance": "example.com", "severity": "critical" },
"commonLabels": {
"alertname": "systemd_unit_failed",
"instance": "example.com",
"job": "node_exporter",
"severity": "critical",
"state": "failed",
"type": "oneshot"
},
"commonAnnotations": { "summary": "Systemd unit failed" },
"externalURL": "http://example.com:9093",
"version": "4",
"groupKey": "{}:{instance=\"example.com\", severity=\"critical\"}",
"truncatedAlerts": 0
}

View file

@ -1,6 +1,6 @@
FROM golang:alpine AS build
FROM golang:alpine as build
WORKDIR /app
ARG VERSION=0.4.0
ARG VERSION=0.3.0
RUN wget https://git.xenrox.net/~xenrox/ntfy-alertmanager/refs/download/v${VERSION}/ntfy-alertmanager-${VERSION}.tar.gz -O latest.tar.gz && \
tar -zxf latest.tar.gz

View file

@ -1,4 +1,4 @@
FROM golang:alpine AS build
FROM golang:alpine as build
WORKDIR /app
COPY . .

View file

@ -1,3 +1,4 @@
version: "3"
services:
ntfy-alertmanager:
image: xenrox/ntfy-alertmanager:latest
@ -8,10 +9,10 @@ services:
- 127.0.0.1:8080:8080
restart: unless-stopped
# Uncomment if you want to use valkey as cache
# valkey:
# image: valkey/valkey
# container_name: valkey
# Uncomment if you want to use redis as cache
# redis:
# image: redis:alpine
# container_name: redis
# restart: unless-stopped
# volumes:
# - ./cache:/data

12
go.mod
View file

@ -1,15 +1,15 @@
module git.xenrox.net/~xenrox/ntfy-alertmanager
go 1.22.0
go 1.21.0
require (
git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813142628-a8bdc9211a98
github.com/redis/go-redis/v9 v9.7.0
golang.org/x/text v0.20.0
git.sr.ht/~emersion/go-scfg v0.0.0-20230601130942-e042ab15616e
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813135846-959a68fe51de
github.com/redis/go-redis/v9 v9.0.5
golang.org/x/text v0.12.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
)

28
go.sum
View file

@ -1,18 +1,18 @@
git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082 h1:9Udx5fm4vRtmgDIBjy2ef5QioHbzpw5oHabbhpAUyEw=
git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082/go.mod h1:ybgvEJTIx5XbaspSviB3KNa6OdPmAZqDoSud7z8fFlw=
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813142628-a8bdc9211a98 h1:c6B8yMLiPWj8Fqp3AeLBB86gKhdz2hfgAupaNpmMRMo=
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813142628-a8bdc9211a98/go.mod h1:BM4sMPD0fqFB6eG1T/7rGgEUiqZsMpHvq4PGE861Sfk=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
git.sr.ht/~emersion/go-scfg v0.0.0-20230601130942-e042ab15616e h1:42zyo0ZFxHGkysM1B9EM7PnQNO0TEzPm+bw/2Zontyg=
git.sr.ht/~emersion/go-scfg v0.0.0-20230601130942-e042ab15616e/go.mod h1:ybgvEJTIx5XbaspSviB3KNa6OdPmAZqDoSud7z8fFlw=
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813135846-959a68fe51de h1:nTecM544Gf8hxxGVSn1dB8LGtGrwr5YAxOEbcJLLjVQ=
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813135846-959a68fe51de/go.mod h1:BM4sMPD0fqFB6eG1T/7rGgEUiqZsMpHvq4PGE861Sfk=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=

117
main.go
View file

@ -14,10 +14,8 @@ import (
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"slices"
@ -34,8 +32,6 @@ import (
var version = "dev"
const maxNTFYActions = 3
type bridge struct {
cfg *config.Config
logger *slog.Logger
@ -56,7 +52,6 @@ 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"`
}
@ -71,18 +66,16 @@ type notification struct {
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 {
func (br *bridge) singleAlertNotifications(p *payload) []*notification {
var notifications []*notification
for _, alert := range p.Alerts {
contains, err := br.cache.Contains(ctx, alert.Fingerprint, alert.Status)
contains, err := br.cache.Contains(alert.Fingerprint, alert.Status)
if err != nil {
br.logger.Error("Failed to lookup alert in cache",
slog.String("fingerprint", alert.Fingerprint),
@ -97,7 +90,6 @@ func (br *bridge) singleAlertNotifications(ctx context.Context, p *payload) []*n
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))
@ -158,10 +150,6 @@ func (br *bridge) singleAlertNotifications(ctx context.Context, p *payload) []*n
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)
@ -272,10 +260,6 @@ func (br *bridge) multiAlertNotification(p *payload) *notification {
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)
@ -304,24 +288,12 @@ func (br *bridge) multiAlertNotification(p *payload) *notification {
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)
func (br *bridge) publish(n *notification) error {
req, err := http.NewRequest(http.MethodPost, br.cfg.Ntfy.Topic, strings.NewReader(n.body))
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)
@ -360,26 +332,13 @@ func (br *bridge) publish(n *notification, topicParam string) error {
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))
req.Header.Set("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 {
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
for _, rawCert := range rawCerts {
hash := sha512.Sum512(rawCert)
if hex.EncodeToString(hash[:]) == configFingerprint {
@ -427,9 +386,14 @@ func (br *bridge) publish(n *notification, topicParam string) error {
}
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)
@ -437,8 +401,6 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
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)
@ -451,14 +413,14 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
slog.Any("payload", event))
if br.cfg.AlertMode == config.Single {
notifications := br.singleAlertNotifications(ctx, &event)
notifications := br.singleAlertNotifications(&event)
for _, n := range notifications {
err := br.publish(n, topicParam)
err := br.publish(n)
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 {
if err := br.cache.Set(n.fingerprint, n.status); err != nil {
logger.Error("Failed to cache alert",
slog.String("fingerprint", n.fingerprint),
slog.String("error", err.Error()))
@ -467,7 +429,7 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
}
} else {
notification := br.multiAlertNotification(&event)
err := br.publish(notification, topicParam)
err := br.publish(notification)
if err != nil {
logger.Error("Failed to publish notification",
slog.String("error", err.Error()))
@ -536,28 +498,14 @@ func main() {
os.Exit(0)
}
cfg, err := config.Read(configPath)
cfg, err := config.ReadConfig(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)
logger, err := logging.New(cfg.LogLevel, cfg.LogFormat, os.Stderr)
if err != nil {
slog.Error("Failed to create logger",
slog.String("error", err.Error()))
@ -569,7 +517,7 @@ func main() {
client := &httpClient{&http.Client{Timeout: time.Second * 3}}
c, err := cache.NewCache(ctx, cfg.Cache)
c, err := cache.NewCache(cfg.Cache)
if err != nil {
logger.Error("Failed to create cache",
slog.String("error", err.Error()))
@ -580,8 +528,8 @@ func main() {
logger.Info(fmt.Sprintf("Listening on %s, ntfy-alertmanager %s", cfg.HTTPAddress, version))
mux := http.NewServeMux()
mux.HandleFunc("POST /", bridge.handleWebhooks)
mux.HandleFunc("POST /silences", bridge.handleSilences)
mux.HandleFunc("/", bridge.handleWebhooks)
mux.HandleFunc("/silences", bridge.handleSilences)
httpServer := &http.Server{
Addr: cfg.HTTPAddress,
@ -619,26 +567,3 @@ func main() {
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
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@ -39,6 +40,12 @@ type silenceResponse struct {
func (br *bridge) handleSilences(w http.ResponseWriter, r *http.Request) {
logger := br.logger.With(slog.String("handler", "/silences"))
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
}
b, err := io.ReadAll(r.Body)
if err != nil {
logger.Error("Failed to read body",