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 image: archlinux
packages: packages:
- docker - docker
- docker-buildx
- go - go
- just
- revive - revive
- staticcheck - staticcheck
secrets: secrets:
- 8da1d834-8c97-4da0-a7ae-dd755b702691 - 2b44595c-3464-4c2d-a8df-c5dea8e90c4c
sources: sources:
- https://git.xenrox.net/~xenrox/ntfy-alertmanager - https://git.xenrox.net/~xenrox/ntfy-alertmanager
tasks: tasks:
- test: | - test: |
cd ntfy-alertmanager cd ntfy-alertmanager
just test go test -v ./...
- lint: | - lint: |
cd ntfy-alertmanager cd ntfy-alertmanager
just lint go vet ./...
staticcheck ./...
revive ./...
- build: | - build: |
cd ntfy-alertmanager cd ntfy-alertmanager
just build go build
- gofmt: | - gofmt: |
cd ntfy-alertmanager cd ntfy-alertmanager
just gofmt test -z $(gofmt -l .)
- dev-image: | - dev-image: |
cd ntfy-alertmanager/docker cd ntfy-alertmanager/docker
if [ "$BUILD_SUBMITTER" != "git.sr.ht" ] || [ "$(git rev-parse master)" != "$(git rev-parse HEAD)" ] if [ "$BUILD_SUBMITTER" != "git.sr.ht" ] || [ "$(git rev-parse master)" != "$(git rev-parse HEAD)" ]
@ -30,10 +30,6 @@ tasks:
complete-build complete-build
fi fi
sudo systemctl start docker sudo systemctl start docker
~/.local/bin/docker_login ~/.local/bin/dockerhub_login
docker build -f Dockerfile-dev -t xenrox/ntfy-alertmanager:dev ./.. docker build -f Dockerfile-dev -t xenrox/ntfy-alertmanager:dev ./..
docker push 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 ## 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:latest` is built from the latest tagged release while
`ntfy-alertmanager:dev` is built from the master branch. `ntfy-alertmanager:dev` is built from the master branch.
On Arch Linux you can install the [aur package] as well. On Arch Linux you can install the [aur package] as well.
@ -19,30 +19,29 @@ of this file is [scfg] and there is an [example configuration file] in this repo
Furthermore you can take a look at [my deployment]. Furthermore you can take a look at [my deployment].
ntfy-alertmanager has support for setting ntfy [priority], [tags], [icon], [action buttons] 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]. (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, an email address or an alternative ntfy topic. 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. - For priority and icon the first found value will be chosen. Settings for "resolved" alerts will take precedence.
- Tags are added together. - Tags are added together.
### Alertmanager config ### Alertmanager config
```yaml ```yaml
receivers: receivers:
- name: "ntfy" - name: "ntfy"
webhook_configs: webhook_configs:
- url: "http://127.0.0.1:8080" - url: "http://127.0.0.1:8080"
http_config: http_config:
basic_auth: basic_auth:
username: "webhookUser" username: "webhookUser"
password: "webhookPass" password: "webhookPass"
``` ```
## Contributing ## Contributing
Report bugs on the [issue tracker], send patches/ask questions on the [mailing list] Report bugs on the [issue tracker], send patches/ask questions on the [mailing list]
or write to me directly on matrix [@xenrox:xenrox.net]. 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/ [ntfy-alertmanager]: https://hub.xenrox.net/~xenrox/ntfy-alertmanager/
[scfg]: https://git.sr.ht/~emersion/scfg [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 [mailing list]: https://lists.xenrox.net/~xenrox/public-inbox
[@xenrox:xenrox.net]: https://matrix.to/#/@xenrox:xenrox.net [@xenrox:xenrox.net]: https://matrix.to/#/@xenrox:xenrox.net
[docker image]: https://hub.docker.com/r/xenrox/ntfy-alertmanager [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/docker-compose.yml
[docker compose file]: https://git.xenrox.net/~xenrox/ntfy-alertmanager/tree/master/item/docker/compose.yaml
[example configuration file]: https://git.xenrox.net/~xenrox/ntfy-alertmanager/tree/master/item/config.scfg [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 [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 [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 package cache
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
@ -11,18 +10,18 @@ import (
// Cache is the interface that describes a cache for ntfy-alertmanager. // Cache is the interface that describes a cache for ntfy-alertmanager.
type Cache interface { type Cache interface {
Set(ctx context.Context, fingerprint string, status string) error Set(fingerprint string, status string) error
Contains(ctx context.Context, fingerprint string, status string) (bool, error) Contains(fingerprint string, status string) (bool, error)
Cleanup() Cleanup()
} }
// NewCache reads the cache configuration cfg and creates the cache. // 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) { switch strings.ToLower(cfg.Type) {
case "memory": case "memory":
return NewMemoryCache(cfg.Duration), nil return NewMemoryCache(cfg.Duration), nil
case "redis": case "redis":
return NewRedisCache(ctx, cfg.RedisURL, cfg.Duration) return NewRedisCache(cfg.RedisURL, cfg.Duration)
case "disabled": case "disabled":
return NewDisabledCache() return NewDisabledCache()
default: default:

6
cache/disabled.go vendored
View file

@ -1,7 +1,5 @@
package cache package cache
import "context"
// DisabledCache is the disabled cache. // DisabledCache is the disabled cache.
type DisabledCache struct{} type DisabledCache struct{}
@ -12,12 +10,12 @@ func NewDisabledCache() (Cache, error) {
} }
// Set is an empty function to implement the interface. // 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 return nil
} }
// Contains is an empty function to implement the interface. // 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 return false, nil
} }

5
cache/memory.go vendored
View file

@ -1,7 +1,6 @@
package cache package cache
import ( import (
"context"
"sync" "sync"
"time" "time"
) )
@ -28,7 +27,7 @@ func NewMemoryCache(d time.Duration) Cache {
} }
// Set saves an alert in the 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
alert := new(cachedAlert) 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 // Contains checks if an alert with a given fingerprint is in the cache
// and if the status matches. // 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
alert, ok := c.alerts[fingerprint] 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. // 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) c := new(RedisCache)
ropts, err := redis.ParseURL(redisURL) ropts, err := redis.ParseURL(redisURL)
if err != nil { if err != nil {
@ -24,7 +24,7 @@ func NewRedisCache(ctx context.Context, redisURL string, d time.Duration) (Cache
} }
rdb := redis.NewClient(ropts) rdb := redis.NewClient(ropts)
ctx, cancel := context.WithTimeout(ctx, redisTimeout) ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
defer cancel() defer cancel()
err = rdb.Ping(ctx).Err() 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. // Set saves an alert in the cache.
func (c *RedisCache) Set(ctx context.Context, fingerprint string, status string) error { func (c *RedisCache) Set(fingerprint string, status string) error {
ctx, cancel := context.WithTimeout(ctx, redisTimeout) ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
defer cancel() defer cancel()
return c.client.SetEx(ctx, fingerprint, status, c.duration).Err() 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 // Contains checks if an alert with a given fingerprint is in the cache
// and if the status matches. // and if the status matches.
func (c *RedisCache) Contains(ctx context.Context, fingerprint string, status string) (bool, error) { func (c *RedisCache) Contains(fingerprint string, status string) (bool, error) {
ctx, cancel := context.WithTimeout(ctx, redisTimeout) ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
defer cancel() defer cancel()
val, err := c.client.Get(ctx, fingerprint).Result() 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. # Public facing base URL of the service (e.g. https://ntfy-alertmanager.xenrox.net)
# 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)
# This setting is required for the "Silence" feature. # This setting is required for the "Silence" feature.
# Default: "" base-url https://ntfy-alertmanager.xenrox.net
base-url https://ntfy-alertmanager.example.com
# http listen address # http listen address
# Default: 127.0.0.1:8080
http-address :8080 http-address :8080
# Log level # Log level (either debug, info, warning, error)
# Options: debug, info, warning, error
# Default: info
log-level info log-level info
# Log format # Log format (either text or json)
# Options: text, json
# Default: text
log-format text 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 # 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) # each on their own (single mode) or be kept together (multi mode) (either single or multi; default is multi)
# Options: single, multi
# Default: multi
alert-mode single alert-mode single
# Optionally protect with HTTP basic authentication # Optionally protect with HTTP basic authentication
# Default: ""
user webhookUser user webhookUser
# Default: ""
password webhookPass password webhookPass
labels { labels {
@ -39,9 +20,9 @@ labels {
severity "critical" { severity "critical" {
priority 5 priority 5
tags "rotating_light" 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. # 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 the specified number. Use `yes` to pick the first of your verified numbers.
call yes call yes
} }
@ -51,7 +32,6 @@ labels {
} }
instance "example.com" { instance "example.com" {
topic homeserver
tags "computer,example" tags "computer,example"
} }
} }
@ -59,47 +39,29 @@ labels {
# Settings for resolved alerts # Settings for resolved alerts
resolved { resolved {
tags "resolved,partying_face" tags "resolved,partying_face"
icon "https://example.com/resolved.png" icon "https://foo.com/resolved.png"
priority 1 priority 1
} }
ntfy { ntfy {
# URL of the ntfy server. # URL of the ntfy topic - required
# Default: "" topic https://ntfy.sh/alertmanager-alerts
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
# ntfy authentication via Basic Auth (https://docs.ntfy.sh/publish/#username-password) # ntfy authentication via Basic Auth (https://docs.ntfy.sh/publish/#username-password)
# Default: ""
user user user user
# Default: ""
password pass password pass
# ntfy authentication via access tokens (https://docs.ntfy.sh/publish/#access-tokens) # 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. # Either access-token or a user/password combination can be used - not both.
# Default: ""
access-token foobar access-token foobar
# When using (self signed) certificates that cannot be verified, you can instead specify # When using (self signed) certificates that cannot be verified, you can instead specify
# the SHA512 fingerprint. # the SHA512 fingerprint.
# openssl can be used to obtain it: # openssl can be used to obtain it:
# openssl s_client -connect HOST:PORT | openssl x509 -fingerprint -sha512 -noout # 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. # 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 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. # Forward all messages to the specified email address.
# Default: "" email-address foo@bar.com
email-address foo@example.com
# Call the specified number for all alerts. Use `yes` to pick the first of your verified numbers. # Call the specified number for all alerts. Use `yes` to pick the first of your verified numbers.
# Default: ""
call +123456789 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 { alertmanager {
@ -111,36 +73,28 @@ alertmanager {
# When alert-mode is set to "single" all alert labels will be used to create the silence. # 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 # When it is "multi" common labels between all the alerts will be used. WARNING: This
# could silence unwanted alerts. # could silence unwanted alerts.
# Default: ""
silence-duration 24h silence-duration 24h
# Basic authentication (https://prometheus.io/docs/alerting/latest/https/) # Basic authentication (https://prometheus.io/docs/alerting/latest/https/)
# Default: ""
user user user user
# Default: ""
password pass password pass
# By default the Alertmanager URL gets parsed from the webhook. In case that # 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. # 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 # When the alert-mode is set to single, ntfy-alertmanager will cache each single alert
# to avoid sending recurrences. # to avoid sending recurrences.
cache { cache {
# The type of cache that will be used # The type of cache that will be used (either disabled, memory or redis; default is disabled).
# Options: disabled, memory, redis
# Default: disabled
type memory type memory
# How long messages stay in the cache for # How long messages stay in the cache for
# Default: 24h
duration 24h duration 24h
# Memory cache settings # Memory cache settings
# Interval in which the cache is cleaned up # Interval in which the cache is cleaned up
# Default: 1h
cleanup-interval 1h cleanup-interval 1h
# Redis cache settings # Redis cache settings
# URL to connect to redis (default: redis://localhost:6379) # URL to connect to redis (default: redis://localhost:6379)
# Default: redis://localhost:6379
redis-url redis://user:password@localhost:6789/3 redis-url redis://user:password@localhost:6789/3
} }

View file

@ -25,7 +25,6 @@ type Config struct {
HTTPAddress string HTTPAddress string
LogLevel string LogLevel string
LogFormat string LogFormat string
LogFile string
AlertMode AlertMode AlertMode AlertMode
User string User string
Password string Password string
@ -37,15 +36,13 @@ type Config struct {
} }
type ntfyConfig struct { type ntfyConfig struct {
Server string Topic string
Topic string User string
User string Password string
Password string AccessToken string
AccessToken string CertFingerprint string
CertFingerprint string EmailAddress string
EmailAddress string Call string
Call string
GeneratorURLLabel string
} }
type labels struct { type labels struct {
@ -59,7 +56,6 @@ type labelConfig struct {
Icon string Icon string
EmailAddress string EmailAddress string
Call string Call string
Topic string
} }
// CacheConfig is the configuration of the cache. // CacheConfig is the configuration of the cache.
@ -86,338 +82,8 @@ type resolvedConfig struct {
Priority string Priority string
} }
func parseBlock(block scfg.Block, config *Config) error { // ReadConfig reads an scfg formatted file and returns the configuration struct.
d := block.Get("log-level") func ReadConfig(path string) (*Config, error) {
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) {
cfg, err := scfg.Load(path) cfg, err := scfg.Load(path)
if err != nil { if err != nil {
return nil, err return nil, err
@ -437,50 +103,326 @@ func Read(path string) (*Config, error) {
// redis // redis
config.Cache.RedisURL = "redis://localhost:6379" config.Cache.RedisURL = "redis://localhost:6379"
includeDirs := cfg.GetAll("include") d := cfg.Get("log-level")
for _, d := range includeDirs { if d != nil {
var includePath string if err := d.ParseParams(&config.LogLevel); err != nil {
if err := d.ParseParams(&includePath); 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 return nil, err
} }
block, err := scfg.Load(includePath) switch strings.ToLower(mode) {
if err != nil { case "single":
return nil, fmt.Errorf("cannot load included config file %q: %v", includePath, err) config.AlertMode = Single
}
if err := parseBlock(block, config); err != nil { case "multi":
return nil, fmt.Errorf("cannot parse included config file %q: %v", includePath, err) config.AlertMode = Multi
default:
return nil, fmt.Errorf("%q directive: illegal mode %q", d.Name, mode)
} }
} }
err = parseBlock(cfg, config) d = cfg.Get("user")
if err != nil { if d != nil {
return nil, err 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 == "") || if (config.Password != "" && config.User == "") ||
(config.Password == "" && config.User != "") { (config.Password == "" && config.User != "") {
return nil, errors.New("user and password have to be set together") 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") 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 == "") || if (config.Ntfy.Password != "" && config.Ntfy.User == "") ||
(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") 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 != "" { 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") return nil, errors.New("ntfy: cannot use both an access-token and a user/password at the same time")
} }
if (config.Am.Password != "" && config.Am.User == "") || d = ntfyDir.Children.Get("certificate-fingerprint")
(config.Am.Password == "" && config.Am.User != "") { if d != nil {
return nil, errors.New("alertmanager: user and password have to be set together") 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 return config, nil

View file

@ -10,11 +10,10 @@ import (
func TestReadConfig(t *testing.T) { func TestReadConfig(t *testing.T) {
configContent := ` configContent := `
base-url https://ntfy-alertmanager.example.com base-url https://ntfy-alertmanager.xenrox.net
http-address :8080 http-address :8080
log-level info log-level info
log-format json log-format json
log-file /var/log/ntfy-alertmanager.log
alert-mode multi alert-mode multi
user webhookUser user webhookUser
password webhookPass password webhookPass
@ -25,8 +24,8 @@ labels {
severity "critical" { severity "critical" {
priority 5 priority 5
tags "rotating_light" tags "rotating_light"
icon "https://example.com/critical.png" icon "https://foo.com/critical.png"
email-address foo@example.com email-address foo@bar.com
call yes call yes
} }
@ -36,30 +35,27 @@ labels {
instance "example.com" { instance "example.com" {
tags "computer,example" tags "computer,example"
topic homeserver
} }
} }
resolved { resolved {
tags "resolved,partying_face" tags "resolved,partying_face"
icon "https://example.com/resolved.png" icon "https://foo.com/resolved.png"
priority 1 priority 1
} }
ntfy { ntfy {
server https://ntfy.sh
topic https://ntfy.sh/alertmanager-alerts 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 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 user user
password pass password pass
generator-url-label source
} }
alertmanager { alertmanager {
silence-duration 24h silence-duration 24h
user user user user
password pass password pass
url https://alertmanager.example.com url https://alertmanager.xenrox.net
} }
cache { cache {
@ -70,35 +66,30 @@ cache {
` `
expectedCfg := &Config{ expectedCfg := &Config{
BaseURL: "https://ntfy-alertmanager.example.com", BaseURL: "https://ntfy-alertmanager.xenrox.net",
HTTPAddress: ":8080", HTTPAddress: ":8080",
LogLevel: "info", LogLevel: "info",
LogFormat: "json", LogFormat: "json",
LogFile: "/var/log/ntfy-alertmanager.log",
AlertMode: Multi, AlertMode: Multi,
User: "webhookUser", User: "webhookUser",
Password: "webhookPass", Password: "webhookPass",
Ntfy: ntfyConfig{ Ntfy: ntfyConfig{
Server: "https://ntfy.sh", Topic: "https://ntfy.sh/alertmanager-alerts",
Topic: "https://ntfy.sh/alertmanager-alerts", User: "user",
User: "user", Password: "pass",
Password: "pass", CertFingerprint: "136d2b889c5736d081b4b29c7909276292cfb86a6bd3ad4635cb7017eb996e28082ab8c6794bf62e817941981d53c807b35c245fb18eb6fb66b5ddb4d05c2991",
CertFingerprint: "136d2b889c5736d081b4b29c7909276292cfb86a6bd3ad4635cb7017eb996e28082ab8c6794bf62e817941981d53c807b35c245fb18eb6fb66b5ddb4d05c2991",
GeneratorURLLabel: "source",
}, },
Labels: labels{Order: []string{"severity", "instance"}, Labels: labels{Order: []string{"severity", "instance"},
Label: map[string]labelConfig{ Label: map[string]labelConfig{
"severity:critical": { "severity:critical": {
Priority: "5", Priority: "5",
Tags: []string{"rotating_light"}, Tags: []string{"rotating_light"},
Icon: "https://example.com/critical.png", Icon: "https://foo.com/critical.png",
EmailAddress: "foo@example.com", EmailAddress: "foo@bar.com",
Call: "yes", Call: "yes",
}, },
"severity:info": {Priority: "1"}, "severity:info": {Priority: "1"},
"instance:example.com": { "instance:example.com": {Tags: []string{"computer", "example"}},
Tags: []string{"computer", "example"},
Topic: "homeserver"},
}, },
}, },
Cache: CacheConfig{ Cache: CacheConfig{
@ -111,11 +102,11 @@ cache {
SilenceDuration: time.Hour * 24, SilenceDuration: time.Hour * 24,
User: "user", User: "user",
Password: "pass", Password: "pass",
URL: "https://alertmanager.example.com", URL: "https://alertmanager.xenrox.net",
}, },
Resolved: resolvedConfig{ Resolved: resolvedConfig{
Tags: []string{"resolved", "partying_face"}, Tags: []string{"resolved", "partying_face"},
Icon: "https://example.com/resolved.png", Icon: "https://foo.com/resolved.png",
Priority: "1", Priority: "1",
}, },
} }
@ -126,7 +117,7 @@ cache {
t.Errorf("failed to write config file: %v", err) t.Errorf("failed to write config file: %v", err)
} }
cfg, err := Read(configPath) cfg, err := ReadConfig(configPath)
if err != nil { if err != nil {
t.Errorf("failed to read config file: %v", err) 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 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 && \ 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 tar -zxf latest.tar.gz

View file

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

View file

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

12
go.mod
View file

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

125
main.go
View file

@ -14,10 +14,8 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"os/signal" "os/signal"
"slices" "slices"
@ -34,8 +32,6 @@ import (
var version = "dev" var version = "dev"
const maxNTFYActions = 3
type bridge struct { type bridge struct {
cfg *config.Config cfg *config.Config
logger *slog.Logger logger *slog.Logger
@ -53,11 +49,10 @@ type payload struct {
} }
type alert struct { type alert struct {
Status string `json:"status"` Status string `json:"status"`
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"` Annotations map[string]string `json:"annotations"`
GeneratorURL string `json:"generatorURL"` Fingerprint string `json:"fingerprint"`
Fingerprint string `json:"fingerprint"`
} }
type notification struct { type notification struct {
@ -71,18 +66,16 @@ type notification struct {
silenceBody string silenceBody string
fingerprint string fingerprint string
status string status string
generatorURL string
topic string
} }
type ntfyError struct { type ntfyError struct {
Error string `json:"error"` Error string `json:"error"`
} }
func (br *bridge) singleAlertNotifications(ctx context.Context, p *payload) []*notification { func (br *bridge) singleAlertNotifications(p *payload) []*notification {
var notifications []*notification var notifications []*notification
for _, alert := range p.Alerts { 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 { if err != nil {
br.logger.Error("Failed to lookup alert in cache", br.logger.Error("Failed to lookup alert in cache",
slog.String("fingerprint", alert.Fingerprint), slog.String("fingerprint", alert.Fingerprint),
@ -97,7 +90,6 @@ func (br *bridge) singleAlertNotifications(ctx context.Context, p *payload) []*n
n := new(notification) n := new(notification)
n.fingerprint = alert.Fingerprint n.fingerprint = alert.Fingerprint
n.status = alert.Status n.status = alert.Status
n.generatorURL = alert.GeneratorURL
// create title // create title
n.title = fmt.Sprintf("[%s]", strings.ToUpper(alert.Status)) 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 n.call = labelConfig.Call
} }
if n.topic == "" {
n.topic = labelConfig.Topic
}
for _, val := range labelConfig.Tags { for _, val := range labelConfig.Tags {
if !slices.Contains(tags, val) { if !slices.Contains(tags, val) {
tags = append(tags, val) tags = append(tags, val)
@ -272,10 +260,6 @@ func (br *bridge) multiAlertNotification(p *payload) *notification {
n.call = labelConfig.Call n.call = labelConfig.Call
} }
if n.topic == "" {
n.topic = labelConfig.Topic
}
for _, val := range labelConfig.Tags { for _, val := range labelConfig.Tags {
if !slices.Contains(tags, val) { if !slices.Contains(tags, val) {
tags = append(tags, val) tags = append(tags, val)
@ -304,24 +288,12 @@ func (br *bridge) multiAlertNotification(p *payload) *notification {
return n return n
} }
func (br *bridge) publish(n *notification, topicParam string) error { func (br *bridge) publish(n *notification) error {
// precedence: topicParam > n.topic > cfg.Ntfy.Topic req, err := http.NewRequest(http.MethodPost, br.cfg.Ntfy.Topic, strings.NewReader(n.body))
if topicParam == "" {
topicParam = n.topic
}
url, err := br.topicURL(topicParam)
if err != nil { if err != nil {
return err return err
} }
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(n.body))
if err != nil {
return err
}
var actions []string
// ntfy authentication // ntfy authentication
if br.cfg.Ntfy.Password != "" && br.cfg.Ntfy.User != "" { if br.cfg.Ntfy.Password != "" && br.cfg.Ntfy.User != "" {
req.SetBasicAuth(br.cfg.Ntfy.User, br.cfg.Ntfy.Password) 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) 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 configFingerprint := br.cfg.Ntfy.CertFingerprint
if configFingerprint != "" { if configFingerprint != "" {
tlsCfg := &tls.Config{} tlsCfg := &tls.Config{}
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error { tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
for _, rawCert := range rawCerts { for _, rawCert := range rawCerts {
hash := sha512.Sum512(rawCert) hash := sha512.Sum512(rawCert)
if hex.EncodeToString(hash[:]) == configFingerprint { 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) { func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := br.logger.With(slog.String("handler", "/")) 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") contentType := r.Header.Get("Content-Type")
if contentType != "application/json" { if contentType != "application/json" {
http.Error(w, "Only application/json allowed", http.StatusUnsupportedMediaType) http.Error(w, "Only application/json allowed", http.StatusUnsupportedMediaType)
@ -437,8 +401,6 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
return return
} }
topicParam := r.URL.Query().Get("topic")
var event payload var event payload
if err := json.NewDecoder(r.Body).Decode(&event); err != nil { if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, "Failed to parse payload", http.StatusInternalServerError) 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)) slog.Any("payload", event))
if br.cfg.AlertMode == config.Single { if br.cfg.AlertMode == config.Single {
notifications := br.singleAlertNotifications(ctx, &event) notifications := br.singleAlertNotifications(&event)
for _, n := range notifications { for _, n := range notifications {
err := br.publish(n, topicParam) err := br.publish(n)
if err != nil { if err != nil {
logger.Error("Failed to publish notification", logger.Error("Failed to publish notification",
slog.String("error", err.Error())) slog.String("error", err.Error()))
} else { } 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", logger.Error("Failed to cache alert",
slog.String("fingerprint", n.fingerprint), slog.String("fingerprint", n.fingerprint),
slog.String("error", err.Error())) slog.String("error", err.Error()))
@ -467,7 +429,7 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
} }
} else { } else {
notification := br.multiAlertNotification(&event) notification := br.multiAlertNotification(&event)
err := br.publish(notification, topicParam) err := br.publish(notification)
if err != nil { if err != nil {
logger.Error("Failed to publish notification", logger.Error("Failed to publish notification",
slog.String("error", err.Error())) slog.String("error", err.Error()))
@ -536,28 +498,14 @@ func main() {
os.Exit(0) os.Exit(0)
} }
cfg, err := config.Read(configPath) cfg, err := config.ReadConfig(configPath)
if err != nil { if err != nil {
slog.Error("Failed to read config", slog.Error("Failed to read config",
slog.String("error", err.Error())) slog.String("error", err.Error()))
os.Exit(1) os.Exit(1)
} }
var logOutput io.Writer logger, err := logging.New(cfg.LogLevel, cfg.LogFormat, os.Stderr)
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 { if err != nil {
slog.Error("Failed to create logger", slog.Error("Failed to create logger",
slog.String("error", err.Error())) slog.String("error", err.Error()))
@ -569,7 +517,7 @@ func main() {
client := &httpClient{&http.Client{Timeout: time.Second * 3}} client := &httpClient{&http.Client{Timeout: time.Second * 3}}
c, err := cache.NewCache(ctx, cfg.Cache) c, err := cache.NewCache(cfg.Cache)
if err != nil { if err != nil {
logger.Error("Failed to create cache", logger.Error("Failed to create cache",
slog.String("error", err.Error())) slog.String("error", err.Error()))
@ -580,8 +528,8 @@ func main() {
logger.Info(fmt.Sprintf("Listening on %s, ntfy-alertmanager %s", cfg.HTTPAddress, version)) logger.Info(fmt.Sprintf("Listening on %s, ntfy-alertmanager %s", cfg.HTTPAddress, version))
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /", bridge.handleWebhooks) mux.HandleFunc("/", bridge.handleWebhooks)
mux.HandleFunc("POST /silences", bridge.handleSilences) mux.HandleFunc("/silences", bridge.handleSilences)
httpServer := &http.Server{ httpServer := &http.Server{
Addr: cfg.HTTPAddress, Addr: cfg.HTTPAddress,
@ -619,26 +567,3 @@ func main() {
slog.String("error", err.Error())) 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" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@ -39,6 +40,12 @@ type silenceResponse struct {
func (br *bridge) handleSilences(w http.ResponseWriter, r *http.Request) { func (br *bridge) handleSilences(w http.ResponseWriter, r *http.Request) {
logger := br.logger.With(slog.String("handler", "/silences")) 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) b, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
logger.Error("Failed to read body", logger.Error("Failed to read body",