Compare commits

..

28 commits

Author SHA1 Message Date
Thorben Günther
3bbde04774
Remove HTTP method checks
This is enforced by the router now.
2024-11-21 13:58:50 +01:00
Thorben Günther
d34f90a4b8
Bump Go version 2024-11-21 13:35:51 +01:00
Thorben Günther
3217cb9b7d
Upgrade dependencies 2024-11-21 13:09:49 +01:00
Thorben Günther
c96c83a19c
docker: Release 0.4.0 2024-11-21 13:05:56 +01:00
Thorben Günther
add22b771a
Remove default ntfy server setting
Setting this to "https://ntfy.sh" has security implications: If the user
forgets to set his server, but uses the new short form for topics, the
notification will be sent to "ntfy.sh" and could expose information.
2024-11-21 12:21:12 +01:00
Thorben Günther
109b0f52c0
Mirror docker image to Codeberg 2024-11-20 14:02:44 +01:00
Thorben Günther
182444df3a
Expose context to cache functions 2024-11-09 14:13:33 +01:00
Thorben Günther
ea58dec539
maxNTFYActions should be const 2024-11-07 20:02:22 +01:00
Thorben Günther
d25169e203
docker: Fix warnings
"FromAsCasing: 'as' and 'FROM' keywords' casing do not match"
2024-11-07 14:24:33 +01:00
Thorben Günther
bd1abedac1
Allow setting topic through URL query parameters
Closes: https://codeberg.org/xenrox/ntfy-alertmanager/issues/2
2024-11-07 14:09:49 +01:00
Thorben Günther
84a45a909b
config: Allow to separate the ntfy topic from the server 2024-11-07 13:54:21 +01:00
Thorben Günther
9b9d71d648
Fix linter warnings 2024-11-06 15:09:49 +01:00
Thorben Günther
dbe860e429
docker: Recommend valkey
References: https://todo.xenrox.net/~xenrox/ntfy-alertmanager/24
2024-11-06 15:06:29 +01:00
Thorben Günther
652c8b32cd
Limit number of ntfy actions
ntfy supports a maximum of three actions. If more a defined, the sending
of the message will fail. To prevent this, the surplus actions will be
removed.
2024-11-06 14:45:15 +01:00
Thorben Günther
e85b5e6ea5
Let a label specify its own ntfy topic
References: https://todo.xenrox.net/~xenrox/ntfy-alertmanager/9
2024-11-06 14:16:36 +01:00
Thorben Günther
f4483532f5
Add support for displaying the alert's GeneratorURL 2024-11-06 14:01:01 +01:00
Thorben Günther
fa1a7916f0
Add testing setup 2024-11-06 13:03:29 +01:00
Thorben Günther
b3d5045ca7
Try out just 2024-11-03 19:38:20 +01:00
Thorben Günther
a4e11fc6c4
ci: Install docker-buildx
Fix for:
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit:
2024-09-22 01:49:28 +02:00
Thorben Günther
6f2df702ed
ci: Push docker images to own registry as well 2024-09-20 00:50:42 +02:00
Thorben Günther
f2cac349c6
Support logging to file 2024-09-11 22:45:56 +02:00
Thorben Günther
a1d620b6d2
Improve example config
List default settings and clearly display options.
2024-09-11 22:38:15 +02:00
Thorben Günther
765235c0ca
config: Remove unused config field
The implementation did not use this field after all.
2024-09-11 22:18:44 +02:00
Thorben Günther
af3857d162
config: Use "example.com" domain
This is recommended by RFC2606[1].

[1]: https://datatracker.ietf.org/doc/html/rfc2606
2024-09-11 22:12:21 +02:00
Thorben Günther
dc9741b798
config: ReadConfig -> Read
The function is already in the config package, the old naming is
redundant.
2024-09-11 22:02:50 +02:00
Thorben Günther
6c2521eeca
config: Support "include" directive
With this directive other configuration files can be imported into the
main config. Can be useful for keeping secrets out of the latter.

Closes: https://todo.xenrox.net/~xenrox/ntfy-alertmanager/23
2024-09-11 21:53:26 +02:00
Thorben Günther
8ea629264d
readme: Link Codeberg mirror 2024-07-23 17:46:13 +02:00
Thorben Günther
7295d210b8
docker: docker-compose.yml -> compose.yaml
Further removed the obsolete "version".
2024-07-23 17:32:20 +02:00
19 changed files with 829 additions and 429 deletions

View file

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

49
.justfile Normal file
View file

@ -0,0 +1,49 @@
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] with [docker-compose file].
Simply use go build or the [docker image] ([Codeberg image mirror]) 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,29 +19,30 @@ 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 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.
(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.
- For priority and icon the first found value will be chosen. Settings for "resolved" alerts will take precedence.
- Tags are added together.
- For priority and icon the first found value will be chosen. Settings for "resolved" alerts will take precedence.
- Tags are added together.
### Alertmanager config
```yaml
receivers:
- name: "ntfy"
webhook_configs:
- url: "http://127.0.0.1:8080"
http_config:
basic_auth:
username: "webhookUser"
password: "webhookPass"
- name: "ntfy"
webhook_configs:
- url: "http://127.0.0.1:8080"
http_config:
basic_auth:
username: "webhookUser"
password: "webhookPass"
```
## Contributing
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
@ -55,7 +56,9 @@ or write to me directly on matrix [@xenrox:xenrox.net].
[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
[docker-compose file]: https://git.xenrox.net/~xenrox/ntfy-alertmanager/tree/master/item/docker/docker-compose.yml
[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
[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,6 +2,7 @@
package cache
import (
"context"
"fmt"
"strings"
@ -10,18 +11,18 @@ import (
// Cache is the interface that describes a cache for ntfy-alertmanager.
type Cache interface {
Set(fingerprint string, status string) error
Contains(fingerprint string, status string) (bool, error)
Set(ctx context.Context, fingerprint string, status string) error
Contains(ctx context.Context, fingerprint string, status string) (bool, error)
Cleanup()
}
// NewCache reads the cache configuration cfg and creates the cache.
func NewCache(cfg config.CacheConfig) (Cache, error) {
func NewCache(ctx context.Context, cfg config.CacheConfig) (Cache, error) {
switch strings.ToLower(cfg.Type) {
case "memory":
return NewMemoryCache(cfg.Duration), nil
case "redis":
return NewRedisCache(cfg.RedisURL, cfg.Duration)
return NewRedisCache(ctx, cfg.RedisURL, cfg.Duration)
case "disabled":
return NewDisabledCache()
default:

6
cache/disabled.go vendored
View file

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

5
cache/memory.go vendored
View file

@ -1,6 +1,7 @@
package cache
import (
"context"
"sync"
"time"
)
@ -27,7 +28,7 @@ func NewMemoryCache(d time.Duration) Cache {
}
// Set saves an alert in the cache.
func (c *MemoryCache) Set(fingerprint string, status string) error {
func (c *MemoryCache) Set(_ context.Context, fingerprint string, status string) error {
c.mu.Lock()
defer c.mu.Unlock()
alert := new(cachedAlert)
@ -40,7 +41,7 @@ func (c *MemoryCache) Set(fingerprint string, status string) error {
// Contains checks if an alert with a given fingerprint is in the cache
// and if the status matches.
func (c *MemoryCache) Contains(fingerprint string, status string) (bool, error) {
func (c *MemoryCache) Contains(_ context.Context, 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(redisURL string, d time.Duration) (Cache, error) {
func NewRedisCache(ctx context.Context, redisURL string, d time.Duration) (Cache, error) {
c := new(RedisCache)
ropts, err := redis.ParseURL(redisURL)
if err != nil {
@ -24,7 +24,7 @@ func NewRedisCache(redisURL string, d time.Duration) (Cache, error) {
}
rdb := redis.NewClient(ropts)
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
ctx, cancel := context.WithTimeout(ctx, redisTimeout)
defer cancel()
err = rdb.Ping(ctx).Err()
@ -38,8 +38,8 @@ func NewRedisCache(redisURL string, d time.Duration) (Cache, error) {
}
// Set saves an alert in the cache.
func (c *RedisCache) Set(fingerprint string, status string) error {
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
func (c *RedisCache) Set(ctx context.Context, fingerprint string, status string) error {
ctx, cancel := context.WithTimeout(ctx, redisTimeout)
defer cancel()
return c.client.SetEx(ctx, fingerprint, status, c.duration).Err()
@ -47,8 +47,8 @@ func (c *RedisCache) Set(fingerprint string, status string) error {
// Contains checks if an alert with a given fingerprint is in the cache
// and if the status matches.
func (c *RedisCache) Contains(fingerprint string, status string) (bool, error) {
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout)
func (c *RedisCache) Contains(ctx context.Context, fingerprint string, status string) (bool, error) {
ctx, cancel := context.WithTimeout(ctx, redisTimeout)
defer cancel()
val, err := c.client.Get(ctx, fingerprint).Result()

View file

@ -1,17 +1,36 @@
# Public facing base URL of the service (e.g. https://ntfy-alertmanager.xenrox.net)
# 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)
# This setting is required for the "Silence" feature.
base-url https://ntfy-alertmanager.xenrox.net
# Default: ""
base-url https://ntfy-alertmanager.example.com
# http listen address
# Default: 127.0.0.1:8080
http-address :8080
# Log level (either debug, info, warning, error)
# Log level
# Options: debug, info, warning, error
# Default: info
log-level info
# Log format (either text or json)
# Log format
# Options: text, json
# Default: 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
# each on their own (single mode) or be kept together (multi mode) (either single or multi; default is multi)
# each on their own (single mode) or be kept together (multi mode)
# Options: single, multi
# Default: multi
alert-mode single
# Optionally protect with HTTP basic authentication
# Default: ""
user webhookUser
# Default: ""
password webhookPass
labels {
@ -20,9 +39,9 @@ labels {
severity "critical" {
priority 5
tags "rotating_light"
icon "https://foo.com/critical.png"
icon "https://example.com/critical.png"
# Forward messages which severity "critical" to the specified email address.
email-address foo@bar.com
email-address foo@example.com
# Call the specified number. Use `yes` to pick the first of your verified numbers.
call yes
}
@ -32,6 +51,7 @@ labels {
}
instance "example.com" {
topic homeserver
tags "computer,example"
}
}
@ -39,29 +59,47 @@ labels {
# Settings for resolved alerts
resolved {
tags "resolved,partying_face"
icon "https://foo.com/resolved.png"
icon "https://example.com/resolved.png"
priority 1
}
ntfy {
# URL of the ntfy topic - required
topic https://ntfy.sh/alertmanager-alerts
# 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
# 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.
email-address foo@bar.com
# Default: ""
email-address foo@example.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 {
@ -73,28 +111,36 @@ 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.xenrox.net
url https://alertmanager.example.com
}
# 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 (either disabled, memory or redis; default is disabled).
# The type of cache that will be used
# Options: disabled, memory, redis
# Default: 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,6 +25,7 @@ type Config struct {
HTTPAddress string
LogLevel string
LogFormat string
LogFile string
AlertMode AlertMode
User string
Password string
@ -36,13 +37,15 @@ type Config struct {
}
type ntfyConfig struct {
Topic string
User string
Password string
AccessToken string
CertFingerprint string
EmailAddress string
Call string
Server string
Topic string
User string
Password string
AccessToken string
CertFingerprint string
EmailAddress string
Call string
GeneratorURLLabel string
}
type labels struct {
@ -56,6 +59,7 @@ type labelConfig struct {
Icon string
EmailAddress string
Call string
Topic string
}
// CacheConfig is the configuration of the cache.
@ -82,8 +86,338 @@ type resolvedConfig struct {
Priority string
}
// ReadConfig reads an scfg formatted file and returns the configuration struct.
func ReadConfig(path string) (*Config, error) {
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) {
cfg, err := scfg.Load(path)
if err != nil {
return nil, err
@ -103,326 +437,50 @@ func ReadConfig(path string) (*Config, error) {
// redis
config.Cache.RedisURL = "redis://localhost:6379"
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 {
includeDirs := cfg.GetAll("include")
for _, d := range includeDirs {
var includePath string
if err := d.ParseParams(&includePath); err != nil {
return nil, err
}
switch strings.ToLower(mode) {
case "single":
config.AlertMode = Single
block, err := scfg.Load(includePath)
if err != nil {
return nil, fmt.Errorf("cannot load 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)
if err := parseBlock(block, config); err != nil {
return nil, fmt.Errorf("cannot parse included config file %q: %v", includePath, err)
}
}
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
}
err = parseBlock(cfg, config)
if 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")
}
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 {
if config.Ntfy.Topic == "" {
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
}
}
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")
}
return config, nil

View file

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

80
contrib/test_payload.json Normal file
View file

@ -0,0 +1,80 @@
{
"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

@ -0,0 +1,80 @@
{
"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.3.0
ARG VERSION=0.4.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,4 +1,3 @@
version: "3"
services:
ntfy-alertmanager:
image: xenrox/ntfy-alertmanager:latest
@ -9,10 +8,10 @@ services:
- 127.0.0.1:8080:8080
restart: unless-stopped
# Uncomment if you want to use redis as cache
# redis:
# image: redis:alpine
# container_name: redis
# Uncomment if you want to use valkey as cache
# valkey:
# image: valkey/valkey
# container_name: valkey
# restart: unless-stopped
# volumes:
# volumes:
# - ./cache:/data

12
go.mod
View file

@ -1,15 +1,15 @@
module git.xenrox.net/~xenrox/ntfy-alertmanager
go 1.21.0
go 1.22.0
require (
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
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
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.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-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=
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=
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.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=
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=

125
main.go
View file

@ -14,8 +14,10 @@ import (
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"slices"
@ -32,6 +34,8 @@ import (
var version = "dev"
const maxNTFYActions = 3
type bridge struct {
cfg *config.Config
logger *slog.Logger
@ -49,10 +53,11 @@ type payload struct {
}
type alert struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
Fingerprint string `json:"fingerprint"`
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
GeneratorURL string `json:"generatorURL"`
Fingerprint string `json:"fingerprint"`
}
type notification struct {
@ -66,16 +71,18 @@ type notification struct {
silenceBody string
fingerprint string
status string
generatorURL string
topic string
}
type ntfyError struct {
Error string `json:"error"`
}
func (br *bridge) singleAlertNotifications(p *payload) []*notification {
func (br *bridge) singleAlertNotifications(ctx context.Context, p *payload) []*notification {
var notifications []*notification
for _, alert := range p.Alerts {
contains, err := br.cache.Contains(alert.Fingerprint, alert.Status)
contains, err := br.cache.Contains(ctx, alert.Fingerprint, alert.Status)
if err != nil {
br.logger.Error("Failed to lookup alert in cache",
slog.String("fingerprint", alert.Fingerprint),
@ -90,6 +97,7 @@ func (br *bridge) singleAlertNotifications(p *payload) []*notification {
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))
@ -150,6 +158,10 @@ func (br *bridge) singleAlertNotifications(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)
@ -260,6 +272,10 @@ 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)
@ -288,12 +304,24 @@ func (br *bridge) multiAlertNotification(p *payload) *notification {
return n
}
func (br *bridge) publish(n *notification) error {
req, err := http.NewRequest(http.MethodPost, br.cfg.Ntfy.Topic, strings.NewReader(n.body))
func (br *bridge) publish(n *notification, topicParam string) error {
// precedence: topicParam > n.topic > cfg.Ntfy.Topic
if topicParam == "" {
topicParam = n.topic
}
url, err := br.topicURL(topicParam)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(n.body))
if err != nil {
return err
}
var actions []string
// ntfy authentication
if br.cfg.Ntfy.Password != "" && br.cfg.Ntfy.User != "" {
req.SetBasicAuth(br.cfg.Ntfy.User, br.cfg.Ntfy.Password)
@ -332,13 +360,26 @@ func (br *bridge) publish(n *notification) error {
authString = fmt.Sprintf(", headers.Authorization=Basic %s", auth)
}
req.Header.Set("Actions", fmt.Sprintf("http, Silence, %s, method=POST, body=%s%s", url, n.silenceBody, authString))
actions = append(actions, fmt.Sprintf("http, Silence, %s, method=POST, body=%s%s", url, n.silenceBody, authString))
}
if br.cfg.Ntfy.GeneratorURLLabel != "" && n.generatorURL != "" {
actions = append(actions, fmt.Sprintf("view, %s, %s", br.cfg.Ntfy.GeneratorURLLabel, n.generatorURL))
}
nActions := len(actions)
if nActions > maxNTFYActions {
br.logger.Warn(fmt.Sprintf("Publish: Too many actions (%d), ntfy only supports up to %d - removing surplus actions.", nActions, maxNTFYActions))
br.logger.Debug("Action list",
slog.Any("actions", actions))
actions = actions[:maxNTFYActions]
}
req.Header.Set("Actions", strings.Join(actions, ";"))
configFingerprint := br.cfg.Ntfy.CertFingerprint
if configFingerprint != "" {
tlsCfg := &tls.Config{}
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
for _, rawCert := range rawCerts {
hash := sha512.Sum512(rawCert)
if hex.EncodeToString(hash[:]) == configFingerprint {
@ -386,14 +427,9 @@ func (br *bridge) publish(n *notification) 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)
@ -401,6 +437,8 @@ 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)
@ -413,14 +451,14 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
slog.Any("payload", event))
if br.cfg.AlertMode == config.Single {
notifications := br.singleAlertNotifications(&event)
notifications := br.singleAlertNotifications(ctx, &event)
for _, n := range notifications {
err := br.publish(n)
err := br.publish(n, topicParam)
if err != nil {
logger.Error("Failed to publish notification",
slog.String("error", err.Error()))
} else {
if err := br.cache.Set(n.fingerprint, n.status); err != nil {
if err := br.cache.Set(ctx, n.fingerprint, n.status); err != nil {
logger.Error("Failed to cache alert",
slog.String("fingerprint", n.fingerprint),
slog.String("error", err.Error()))
@ -429,7 +467,7 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
}
} else {
notification := br.multiAlertNotification(&event)
err := br.publish(notification)
err := br.publish(notification, topicParam)
if err != nil {
logger.Error("Failed to publish notification",
slog.String("error", err.Error()))
@ -498,14 +536,28 @@ func main() {
os.Exit(0)
}
cfg, err := config.ReadConfig(configPath)
cfg, err := config.Read(configPath)
if err != nil {
slog.Error("Failed to read config",
slog.String("error", err.Error()))
os.Exit(1)
}
logger, err := logging.New(cfg.LogLevel, cfg.LogFormat, os.Stderr)
var logOutput io.Writer
if cfg.LogFile != "" {
f, err := os.OpenFile(cfg.LogFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
if err != nil {
slog.Error("Failed to open logfile",
slog.String("error", err.Error()))
os.Exit(1)
}
defer f.Close()
logOutput = f
} else {
logOutput = os.Stderr
}
logger, err := logging.New(cfg.LogLevel, cfg.LogFormat, logOutput)
if err != nil {
slog.Error("Failed to create logger",
slog.String("error", err.Error()))
@ -517,7 +569,7 @@ func main() {
client := &httpClient{&http.Client{Timeout: time.Second * 3}}
c, err := cache.NewCache(cfg.Cache)
c, err := cache.NewCache(ctx, cfg.Cache)
if err != nil {
logger.Error("Failed to create cache",
slog.String("error", err.Error()))
@ -528,8 +580,8 @@ func main() {
logger.Info(fmt.Sprintf("Listening on %s, ntfy-alertmanager %s", cfg.HTTPAddress, version))
mux := http.NewServeMux()
mux.HandleFunc("/", bridge.handleWebhooks)
mux.HandleFunc("/silences", bridge.handleSilences)
mux.HandleFunc("POST /", bridge.handleWebhooks)
mux.HandleFunc("POST /silences", bridge.handleSilences)
httpServer := &http.Server{
Addr: cfg.HTTPAddress,
@ -567,3 +619,26 @@ 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,7 +4,6 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@ -40,12 +39,6 @@ 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",