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,8 +19,8 @@ of this file is [scfg] and there is an [example configuration file] in this repo
Furthermore you can take a look at [my deployment]. 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.
@ -42,6 +42,7 @@ receivers:
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,6 +37,7 @@ type Config struct {
} }
type ntfyConfig struct { type ntfyConfig struct {
Server string
Topic string Topic string
User string User string
Password string Password string
@ -43,6 +45,7 @@ type ntfyConfig struct {
CertFingerprint string CertFingerprint string
EmailAddress string EmailAddress string
Call 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,327 +437,51 @@ 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 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 == "") || if (config.Am.Password != "" && config.Am.User == "") ||
(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 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{
Server: "https://ntfy.sh",
Topic: "https://ntfy.sh/alertmanager-alerts", Topic: "https://ntfy.sh/alertmanager-alerts",
User: "user", User: "user",
Password: "pass", Password: "pass",
CertFingerprint: "136d2b889c5736d081b4b29c7909276292cfb86a6bd3ad4635cb7017eb996e28082ab8c6794bf62e817941981d53c807b35c245fb18eb6fb66b5ddb4d05c2991", CertFingerprint: "136d2b889c5736d081b4b29c7909276292cfb86a6bd3ad4635cb7017eb996e28082ab8c6794bf62e817941981d53c807b35c245fb18eb6fb66b5ddb4d05c2991",
GeneratorURLLabel: "source",
}, },
Labels: labels{Order: []string{"severity", "instance"}, Labels: labels{Order: []string{"severity", "instance"},
Label: map[string]labelConfig{ Label: map[string]labelConfig{
"severity:critical": { "severity:critical": {
Priority: "5", Priority: "5",
Tags: []string{"rotating_light"}, Tags: []string{"rotating_light"},
Icon: "https://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=

117
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
@ -52,6 +56,7 @@ type alert struct {
Status string `json:"status"` Status string `json:"status"`
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"` Annotations map[string]string `json:"annotations"`
GeneratorURL string `json:"generatorURL"`
Fingerprint string `json:"fingerprint"` Fingerprint string `json:"fingerprint"`
} }
@ -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",