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 image: archlinux
packages: packages:
- docker - docker
- docker-buildx
- go - go
- just
- revive - revive
- staticcheck - staticcheck
secrets: secrets:
- 2b44595c-3464-4c2d-a8df-c5dea8e90c4c - 8da1d834-8c97-4da0-a7ae-dd755b702691
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
go test -v ./... just test
- lint: | - lint: |
cd ntfy-alertmanager cd ntfy-alertmanager
go vet ./... just lint
staticcheck ./...
revive ./...
- build: | - build: |
cd ntfy-alertmanager cd ntfy-alertmanager
go build just build
- gofmt: | - gofmt: |
cd ntfy-alertmanager cd ntfy-alertmanager
test -z $(gofmt -l .) just gofmt
- 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,6 +30,10 @@ tasks:
complete-build complete-build
fi fi
sudo systemctl start docker sudo systemctl start docker
~/.local/bin/dockerhub_login ~/.local/bin/docker_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

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 ## 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: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,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]. 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 create an Alertmanager silence), [email notifications] and [phone calls]. (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 or an email address. 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. - 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
@ -55,7 +56,9 @@ or write to me directly on matrix [@xenrox:xenrox.net].
[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
[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 [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,6 +2,7 @@
package cache package cache
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
@ -10,18 +11,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(fingerprint string, status string) error Set(ctx context.Context, fingerprint string, status string) error
Contains(fingerprint string, status string) (bool, error) Contains(ctx context.Context, 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(cfg config.CacheConfig) (Cache, error) { func NewCache(ctx context.Context, 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(cfg.RedisURL, cfg.Duration) return NewRedisCache(ctx, cfg.RedisURL, cfg.Duration)
case "disabled": case "disabled":
return NewDisabledCache() return NewDisabledCache()
default: default:

6
cache/disabled.go vendored
View file

@ -1,5 +1,7 @@
package cache package cache
import "context"
// DisabledCache is the disabled cache. // DisabledCache is the disabled cache.
type DisabledCache struct{} type DisabledCache struct{}
@ -10,12 +12,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(_ string, _ string) error { func (c *DisabledCache) Set(_ context.Context, _ 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(_ string, _ string) (bool, error) { func (c *DisabledCache) Contains(_ context.Context, _ string, _ string) (bool, error) {
return false, nil return false, nil
} }

5
cache/memory.go vendored
View file

@ -1,6 +1,7 @@
package cache package cache
import ( import (
"context"
"sync" "sync"
"time" "time"
) )
@ -27,7 +28,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(fingerprint string, status string) error { func (c *MemoryCache) Set(_ context.Context, 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)
@ -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 // 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(fingerprint string, status string) (bool, error) { func (c *MemoryCache) Contains(_ context.Context, 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(redisURL string, d time.Duration) (Cache, error) { func NewRedisCache(ctx context.Context, 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(redisURL string, d time.Duration) (Cache, error) {
} }
rdb := redis.NewClient(ropts) rdb := redis.NewClient(ropts)
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout) ctx, cancel := context.WithTimeout(ctx, redisTimeout)
defer cancel() defer cancel()
err = rdb.Ping(ctx).Err() 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. // Set saves an alert in the cache.
func (c *RedisCache) Set(fingerprint string, status string) error { func (c *RedisCache) Set(ctx context.Context, fingerprint string, status string) error {
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout) ctx, cancel := context.WithTimeout(ctx, 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(fingerprint string, status string) error {
// 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(fingerprint string, status string) (bool, error) { func (c *RedisCache) Contains(ctx context.Context, fingerprint string, status string) (bool, error) {
ctx, cancel := context.WithTimeout(context.TODO(), redisTimeout) ctx, cancel := context.WithTimeout(ctx, redisTimeout)
defer cancel() defer cancel()
val, err := c.client.Get(ctx, fingerprint).Result() 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. # 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 # http listen address
# Default: 127.0.0.1:8080
http-address :8080 http-address :8080
# Log level (either debug, info, warning, error) # Log level
# Options: debug, info, warning, error
# Default: info
log-level info log-level info
# Log format (either text or json) # Log format
# 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) (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 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 {
@ -20,9 +39,9 @@ labels {
severity "critical" { severity "critical" {
priority 5 priority 5
tags "rotating_light" 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. # 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 the specified number. Use `yes` to pick the first of your verified numbers.
call yes call yes
} }
@ -32,6 +51,7 @@ labels {
} }
instance "example.com" { instance "example.com" {
topic homeserver
tags "computer,example" tags "computer,example"
} }
} }
@ -39,29 +59,47 @@ labels {
# Settings for resolved alerts # Settings for resolved alerts
resolved { resolved {
tags "resolved,partying_face" tags "resolved,partying_face"
icon "https://foo.com/resolved.png" icon "https://example.com/resolved.png"
priority 1 priority 1
} }
ntfy { ntfy {
# URL of the ntfy topic - required # URL of the ntfy server.
topic https://ntfy.sh/alertmanager-alerts # 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) # 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.
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. # 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 {
@ -73,28 +111,36 @@ 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.xenrox.net url https://alertmanager.example.com
} }
# 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 (either disabled, memory or redis; default is disabled). # The type of cache that will be used
# 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,6 +25,7 @@ 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
@ -36,13 +37,15 @@ type Config struct {
} }
type ntfyConfig struct { type ntfyConfig struct {
Topic string Server string
User string Topic string
Password string User string
AccessToken string Password string
CertFingerprint string AccessToken string
EmailAddress string CertFingerprint string
Call string EmailAddress string
Call string
GeneratorURLLabel string
} }
type labels struct { type labels struct {
@ -56,6 +59,7 @@ 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.
@ -82,8 +86,338 @@ type resolvedConfig struct {
Priority string Priority string
} }
// ReadConfig reads an scfg formatted file and returns the configuration struct. func parseBlock(block scfg.Block, config *Config) error {
func ReadConfig(path string) (*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) cfg, err := scfg.Load(path)
if err != nil { if err != nil {
return nil, err return nil, err
@ -103,326 +437,50 @@ func ReadConfig(path string) (*Config, error) {
// redis // redis
config.Cache.RedisURL = "redis://localhost:6379" config.Cache.RedisURL = "redis://localhost:6379"
d := cfg.Get("log-level") includeDirs := cfg.GetAll("include")
if d != nil { for _, d := range includeDirs {
if err := d.ParseParams(&config.LogLevel); err != nil { var includePath string
return nil, err if err := d.ParseParams(&includePath); err != nil {
}
}
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
} }
switch strings.ToLower(mode) { block, err := scfg.Load(includePath)
case "single": if err != nil {
config.AlertMode = Single return nil, fmt.Errorf("cannot load included config file %q: %v", includePath, err)
}
case "multi": if err := parseBlock(block, config); err != nil {
config.AlertMode = Multi return nil, fmt.Errorf("cannot parse included config file %q: %v", includePath, err)
default:
return nil, fmt.Errorf("%q directive: illegal mode %q", d.Name, mode)
} }
} }
d = cfg.Get("user") err = parseBlock(cfg, config)
if d != nil { if err != nil {
if err := d.ParseParams(&config.User); err != nil { return nil, err
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")
} }
labelsDir := cfg.Get("labels") if config.Ntfy.Topic == "" {
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")
} }
d = ntfyDir.Children.Get("certificate-fingerprint") if (config.Am.Password != "" && config.Am.User == "") ||
if d != nil { (config.Am.Password == "" && config.Am.User != "") {
if err := d.ParseParams(&config.Ntfy.CertFingerprint); err != nil { return nil, errors.New("alertmanager: user and password have to be set together")
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,10 +10,11 @@ import (
func TestReadConfig(t *testing.T) { func TestReadConfig(t *testing.T) {
configContent := ` configContent := `
base-url https://ntfy-alertmanager.xenrox.net base-url https://ntfy-alertmanager.example.com
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
@ -24,8 +25,8 @@ labels {
severity "critical" { severity "critical" {
priority 5 priority 5
tags "rotating_light" tags "rotating_light"
icon "https://foo.com/critical.png" icon "https://example.com/critical.png"
email-address foo@bar.com email-address foo@example.com
call yes call yes
} }
@ -35,27 +36,30 @@ 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://foo.com/resolved.png" icon "https://example.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.xenrox.net url https://alertmanager.example.com
} }
cache { cache {
@ -66,30 +70,35 @@ cache {
` `
expectedCfg := &Config{ expectedCfg := &Config{
BaseURL: "https://ntfy-alertmanager.xenrox.net", BaseURL: "https://ntfy-alertmanager.example.com",
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{
Topic: "https://ntfy.sh/alertmanager-alerts", Server: "https://ntfy.sh",
User: "user", Topic: "https://ntfy.sh/alertmanager-alerts",
Password: "pass", User: "user",
CertFingerprint: "136d2b889c5736d081b4b29c7909276292cfb86a6bd3ad4635cb7017eb996e28082ab8c6794bf62e817941981d53c807b35c245fb18eb6fb66b5ddb4d05c2991", Password: "pass",
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://foo.com/critical.png", Icon: "https://example.com/critical.png",
EmailAddress: "foo@bar.com", EmailAddress: "foo@example.com",
Call: "yes", Call: "yes",
}, },
"severity:info": {Priority: "1"}, "severity:info": {Priority: "1"},
"instance:example.com": {Tags: []string{"computer", "example"}}, "instance:example.com": {
Tags: []string{"computer", "example"},
Topic: "homeserver"},
}, },
}, },
Cache: CacheConfig{ Cache: CacheConfig{
@ -102,11 +111,11 @@ cache {
SilenceDuration: time.Hour * 24, SilenceDuration: time.Hour * 24,
User: "user", User: "user",
Password: "pass", Password: "pass",
URL: "https://alertmanager.xenrox.net", URL: "https://alertmanager.example.com",
}, },
Resolved: resolvedConfig{ Resolved: resolvedConfig{
Tags: []string{"resolved", "partying_face"}, Tags: []string{"resolved", "partying_face"},
Icon: "https://foo.com/resolved.png", Icon: "https://example.com/resolved.png",
Priority: "1", Priority: "1",
}, },
} }
@ -117,7 +126,7 @@ cache {
t.Errorf("failed to write config file: %v", err) t.Errorf("failed to write config file: %v", err)
} }
cfg, err := ReadConfig(configPath) cfg, err := Read(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)
} }

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 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 && \ 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,4 +1,3 @@
version: "3"
services: services:
ntfy-alertmanager: ntfy-alertmanager:
image: xenrox/ntfy-alertmanager:latest image: xenrox/ntfy-alertmanager:latest
@ -9,10 +8,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 redis as cache # Uncomment if you want to use valkey as cache
# redis: # valkey:
# image: redis:alpine # image: valkey/valkey
# container_name: redis # container_name: valkey
# 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.21.0 go 1.22.0
require ( require (
git.sr.ht/~emersion/go-scfg v0.0.0-20230601130942-e042ab15616e git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813135846-959a68fe51de git.xenrox.net/~xenrox/go-utils v0.0.0-20230813142628-a8bdc9211a98
github.com/redis/go-redis/v9 v9.0.5 github.com/redis/go-redis/v9 v9.7.0
golang.org/x/text v0.12.0 golang.org/x/text v0.20.0
) )
require ( 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 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-20240128091534-2ae16e782082 h1:9Udx5fm4vRtmgDIBjy2ef5QioHbzpw5oHabbhpAUyEw=
git.sr.ht/~emersion/go-scfg v0.0.0-20230601130942-e042ab15616e/go.mod h1:ybgvEJTIx5XbaspSviB3KNa6OdPmAZqDoSud7z8fFlw= git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082/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-20230813142628-a8bdc9211a98 h1:c6B8yMLiPWj8Fqp3AeLBB86gKhdz2hfgAupaNpmMRMo=
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813135846-959a68fe51de/go.mod h1:BM4sMPD0fqFB6eG1T/7rGgEUiqZsMpHvq4PGE861Sfk= git.xenrox.net/~xenrox/go-utils v0.0.0-20230813142628-a8bdc9211a98/go.mod h1:BM4sMPD0fqFB6eG1T/7rGgEUiqZsMpHvq4PGE861Sfk=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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 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.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=

125
main.go
View file

@ -14,8 +14,10 @@ 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"
@ -32,6 +34,8 @@ 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
@ -49,10 +53,11 @@ 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"`
Fingerprint string `json:"fingerprint"` GeneratorURL string `json:"generatorURL"`
Fingerprint string `json:"fingerprint"`
} }
type notification struct { type notification struct {
@ -66,16 +71,18 @@ 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(p *payload) []*notification { func (br *bridge) singleAlertNotifications(ctx context.Context, p *payload) []*notification {
var notifications []*notification var notifications []*notification
for _, alert := range p.Alerts { 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 { 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),
@ -90,6 +97,7 @@ func (br *bridge) singleAlertNotifications(p *payload) []*notification {
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))
@ -150,6 +158,10 @@ func (br *bridge) singleAlertNotifications(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)
@ -260,6 +272,10 @@ 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)
@ -288,12 +304,24 @@ func (br *bridge) multiAlertNotification(p *payload) *notification {
return n return n
} }
func (br *bridge) publish(n *notification) error { func (br *bridge) publish(n *notification, topicParam string) error {
req, err := http.NewRequest(http.MethodPost, br.cfg.Ntfy.Topic, strings.NewReader(n.body)) // precedence: topicParam > n.topic > cfg.Ntfy.Topic
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)
@ -332,13 +360,26 @@ func (br *bridge) publish(n *notification) error {
authString = fmt.Sprintf(", headers.Authorization=Basic %s", auth) 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 configFingerprint := br.cfg.Ntfy.CertFingerprint
if configFingerprint != "" { if configFingerprint != "" {
tlsCfg := &tls.Config{} tlsCfg := &tls.Config{}
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*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 {
@ -386,14 +427,9 @@ func (br *bridge) publish(n *notification) 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)
@ -401,6 +437,8 @@ 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)
@ -413,14 +451,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(&event) notifications := br.singleAlertNotifications(ctx, &event)
for _, n := range notifications { for _, n := range notifications {
err := br.publish(n) err := br.publish(n, topicParam)
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(n.fingerprint, n.status); err != nil { if err := br.cache.Set(ctx, 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()))
@ -429,7 +467,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) err := br.publish(notification, topicParam)
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()))
@ -498,14 +536,28 @@ func main() {
os.Exit(0) os.Exit(0)
} }
cfg, err := config.ReadConfig(configPath) cfg, err := config.Read(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)
} }
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 { 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()))
@ -517,7 +569,7 @@ func main() {
client := &httpClient{&http.Client{Timeout: time.Second * 3}} client := &httpClient{&http.Client{Timeout: time.Second * 3}}
c, err := cache.NewCache(cfg.Cache) c, err := cache.NewCache(ctx, 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()))
@ -528,8 +580,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("/", bridge.handleWebhooks) mux.HandleFunc("POST /", bridge.handleWebhooks)
mux.HandleFunc("/silences", bridge.handleSilences) mux.HandleFunc("POST /silences", bridge.handleSilences)
httpServer := &http.Server{ httpServer := &http.Server{
Addr: cfg.HTTPAddress, Addr: cfg.HTTPAddress,
@ -567,3 +619,26 @@ 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,7 +4,6 @@ import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@ -40,12 +39,6 @@ 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",