Compare commits

...

23 commits
0.1.5 ... main

Author SHA1 Message Date
Renovate Bot
18612c7eff chore(deps): update golang docker tag to v1.22 2024-02-07 03:02:15 +00:00
Renovate Bot
f8a25d4335 chore(deps): update alpine docker tag to v3.19 2023-12-08 02:02:26 +00:00
Renovate Bot
7384d97afb chore(deps): update actions/setup-go action to v5 2023-12-06 16:00:58 +00:00
Renovate Bot
45bf45902c chore(deps): update actions/checkout action to v4 2023-11-01 21:58:08 +00:00
a502442913
chore: bump go to 1.21 in go.mod 2023-09-11 20:01:53 +02:00
dd38badc6e
style: run formatter 2023-09-11 20:01:26 +02:00
c1f23220fa
docs: add README instructions 2023-09-11 19:58:02 +02:00
773555491a
feat: add alertmanager handler 2023-09-11 18:56:14 +02:00
1bc747c18b
chore: remove errSkipNotification 2023-09-11 18:47:25 +02:00
a428a61fc3
chore: update dockerignore to remove direnv 2023-09-11 17:59:28 +02:00
0ef9170f4e
chore: configure renovate 2023-09-03 18:05:52 +02:00
b73a80b4fa
chore: enable debug in skaffold 2023-09-03 17:09:01 +02:00
c832ea4645
feat: add discord_embed handler 2023-09-03 17:09:00 +02:00
64e91cb2d4
fix: use default topic if none is given 2023-09-03 16:45:46 +02:00
ac2940ccfe
feat: add support for notification action 2023-09-03 16:44:23 +02:00
ea22fa5569
refactor: make handlers able to send multiple notifications at once 2023-09-03 16:44:23 +02:00
1b48840c8b
chore: return 204 when notification is send 2023-09-03 16:36:26 +02:00
8c3cd0f286
chore: enable local build in skaffold 2023-09-03 14:58:47 +02:00
377cb91c67
chore: update bridge http address 2023-09-03 14:58:38 +02:00
ec3c0cfedd
fix: use correct service for ntfy 2023-09-03 14:58:00 +02:00
917d7571f2
refactor: skip notification via err 2023-09-03 14:57:43 +02:00
9f303dcb89
chore: add dockerignore 2023-09-03 13:51:15 +02:00
6b035f4deb
feat: notify ReconciliationSucceeded only after modification 2023-09-03 13:44:16 +02:00
17 changed files with 348 additions and 58 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
/.direnv
/.git
/k8s
/flake.*
/LICENSE
/dist
/renovate.json
/skaffold.yaml

View file

@ -14,10 +14,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: '1.21'

View file

@ -1,4 +1,4 @@
FROM golang:1.21-bookworm as builder
FROM golang:1.22-bookworm as builder
WORKDIR /app

View file

@ -1,3 +1,48 @@
# ntfy-bridge
Bridge for various implementations to publish to ntfy.
Bridge for various implementations to publish to ntfy.
## Installation
Using go:
```sh
go install forge.babariviere.com/babariviere/ntfy-bridge@latest
```
Or using docker:
```sh
docker pull forge.babariviere.com/babariviere/ntfy-bridge:latest
```
Binaries are also avaiable in the [release section](https://forge.babariviere.com/babariviere/ntfy-bridge/releases).
## Usage
First, you need to create a configuration file. A sample one is provided [here](./config.example.scfg).
For now, we have these handler types:
- `flux`: handle notification from [Flux](https://fluxcd.io)
- `discord_embed`: handle preformated notification from discord embeds (see [embed object](https://discord.com/developers/docs/resources/channel#embed-object))
- `alertmanager`: handle notification from alertmanager using [webhook_config](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config)
Once you have created your config file, you can either put it in these directories:
- `/etc/ntfy-bridge/config.scfg`
- `$HOME/.ntfy-bridge/config.scfg`
- `$HOME/.config/ntfy-bridge/config.scfg`
- `config.scfg` (current directory)
Then, you can simply run the binary with either the native binary:
```sh
./ntfy-bridge
```
Or via docker:
```sh
docker run -v config.scfg:/etc/ntfy-bridge/config.scfg -p 8080 forge.babariviere.com/babariviere/ntfy-bridge:latest
```
Sample config for kubernetes can be found in [./k8s/](./k8s/) directory.

70
bridge/alertmanager.go Normal file
View file

@ -0,0 +1,70 @@
package bridge
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
)
type AlertmanagerEvent struct {
Receiver string `json:"receiver"`
Status string `json:"status"`
Alerts []struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
GeneratorURL string `json:"generatorURL"`
Fingerprint string `json:"fingerprint"`
} `json:"alerts"`
GroupLabels map[string]string `json:"groupLabels"`
CommonLabels map[string]string `json:"commonLabels"`
CommonAnnotations map[string]string `json:"commonAnnotations"`
ExternalURL string `json:"externalURL"`
Version string `json:"version"`
GroupKey string `json:"groupKey"`
TruncatedAlerts int `json:"truncatedAlerts"`
}
type AlertmanagerHandler struct{}
func NewAlertmanagerHandler() AlertmanagerHandler {
return AlertmanagerHandler{}
}
func (d AlertmanagerHandler) ProduceNotifications(r *http.Request) ([]Notification, error) {
l := slog.With(slog.String("handler", "alertmanager"))
dec := json.NewDecoder(r.Body)
defer r.Body.Close()
var event AlertmanagerEvent
if err := dec.Decode(&event); err != nil {
l.Error("invalid message format", "error", err)
return nil, err
}
notifications := make([]Notification, 0, len(event.Alerts))
for _, alert := range event.Alerts {
if alert.Annotations["summary"] == "" {
continue
}
var not Notification
not.Title = "[" + strings.ToUpper(event.Status) + "] " + alert.Annotations["summary"]
not.Body = alert.Annotations["description"]
if runbook := alert.Annotations["runbook_url"]; runbook != "" {
not.Actions = append(not.Actions, NewViewAction("Runbook", runbook))
}
if event.Status == "resolved" {
not.Tags = []string{"resolved"}
}
notifications = append(notifications, not)
}
return notifications, nil
}

View file

@ -4,7 +4,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
@ -12,7 +11,7 @@ import (
)
type Handler interface {
FormatNotification(r io.Reader) (Notification, error)
ProduceNotifications(r *http.Request) ([]Notification, error)
}
type NotificationError struct {
@ -20,11 +19,37 @@ type NotificationError struct {
Error string `json:"error"`
}
type NotificationAction struct {
Action string
Label string
Params []string
}
func NewViewAction(label, url string, params ...string) NotificationAction {
return NotificationAction{
Action: "view",
Label: label,
Params: append([]string{url}, params...),
}
}
func (n NotificationAction) Format() string {
var sb strings.Builder
sb.WriteString(n.Action + ", " + n.Label)
if len(n.Params) > 0 {
sb.WriteString(", " + strings.Join(n.Params, ", "))
}
return sb.String()
}
type Notification struct {
Title string
Body string
Priority int
Tags []string
Actions []NotificationAction
IsMarkdown bool
topic string
@ -67,6 +92,15 @@ func (n Notification) Send(base string) error {
req.Header.Set("Authorization", n.auth.bearer())
}
if len(n.Actions) > 0 {
actions := make([]string, len(n.Actions))
for i, act := range n.Actions {
actions[i] = act.Format()
}
req.Header.Set("Actions", strings.Join(actions, "; "))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
@ -128,31 +162,30 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
not, err := b.h.FormatNotification(r.Body)
defer r.Body.Close()
nots, err := b.h.ProduceNotifications(r)
if err != nil {
slog.Error("failed to format notification")
w.WriteHeader(http.StatusBadRequest)
return
}
// If notification is empty, that means it should be ignored.
// TODO: maybe return an error instead of empty notification
if not.IsEmpty() {
w.WriteHeader(http.StatusOK)
if len(nots) == 0 {
slog.Debug("no notification produced")
w.WriteHeader(http.StatusNoContent)
return
}
not.topic = b.topic
not.auth = b.auth
if err = not.Send(b.baseURL); err != nil {
slog.Error("unable to send notification", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
for _, not := range nots {
not.topic = b.topic
not.auth = b.auth
if err = not.Send(b.baseURL); err != nil {
slog.Error("unable to send notification", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
slog.Debug("notification sent with success")
slog.Debug("notifications sent with success", "sent", len(nots))
w.WriteHeader(http.StatusOK)
w.WriteHeader(http.StatusNoContent)
}

74
bridge/discord.go Normal file
View file

@ -0,0 +1,74 @@
package bridge
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
)
type DiscordMessage struct {
Content string `json:"content"`
Embeds []struct {
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Footer struct {
Text string `json:"text"`
} `json:"footer"`
Author struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"author"`
} `json:"embeds"`
}
type DiscordEmbedHandler struct{}
func NewDiscordEmbedHandler() DiscordEmbedHandler {
return DiscordEmbedHandler{}
}
func (d DiscordEmbedHandler) ProduceNotifications(r *http.Request) ([]Notification, error) {
l := slog.With(slog.String("handler", "discord_embed"))
dec := json.NewDecoder(r.Body)
defer r.Body.Close()
var not DiscordMessage
if err := dec.Decode(&not); err != nil {
l.Error("invalid message format", "error", err)
return nil, err
}
notifications := make([]Notification, len(not.Embeds))
for i, embed := range not.Embeds {
not := notifications[i]
not.Title = embed.Title
not.IsMarkdown = true
if embed.URL != "" {
not.Actions = []NotificationAction{NewViewAction("Open in Browser", embed.URL)}
}
var body strings.Builder
body.WriteString(embed.Description)
if embed.Author.Name != "" {
body.WriteString("\n\n**Author**\n")
body.WriteString(embed.Author.Name)
if embed.Author.URL != "" {
body.WriteString(" (" + embed.Author.URL + ")")
}
}
if embed.Footer.Text != "" {
body.WriteString("\n\n" + embed.Footer.Text)
}
not.Body = body.String()
notifications[i] = not
}
return notifications, nil
}

View file

@ -3,26 +3,32 @@ package bridge
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
type fluxInvolvedObject struct {
Kind string `json:"kind"`
Namespace string `json:"namespace"`
Name string `json:"name"`
UID string `json:"uid"`
APIVersion string `json:"apiVersion"`
ResourceVersion string `json:"resourceVersion"`
}
func (f fluxInvolvedObject) String() string {
return strings.ToLower(f.Kind) + "/" + f.Namespace + "." + f.Name
}
type FluxNotification struct {
InvolvedObject struct {
Kind string `json:"kind"`
Namespace string `json:"namespace"`
Name string `json:"name"`
UID string `json:"uid"`
APIVersion string `json:"apiVersion"`
ResourceVersion string `json:"resourceVersion"`
} `json:"involvedObject"`
Severity string `json:"severity"`
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
Reason string `json:"reason"`
Metadata struct {
InvolvedObject fluxInvolvedObject `json:"involvedObject"`
Severity string `json:"severity"`
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
Reason string `json:"reason"`
Metadata struct {
CommitStatus string `json:"commit_status"`
Revision string `json:"revision"`
Summary string `json:"summary"`
@ -31,37 +37,51 @@ type FluxNotification struct {
ReportingInstance string `json:"reportingInstance"`
}
type FluxHandler struct{}
func NewFluxHandler() FluxHandler {
return FluxHandler{}
type FluxHandler struct {
// Register all modifications of reconciliations
reconciliations map[string]bool
}
func (f FluxHandler) FormatNotification(r io.Reader) (Notification, error) {
func NewFluxHandler() FluxHandler {
return FluxHandler{
reconciliations: make(map[string]bool),
}
}
func (f FluxHandler) ProduceNotifications(r *http.Request) ([]Notification, error) {
l := slog.With(slog.String("handler", "flux"))
dec := json.NewDecoder(r)
dec := json.NewDecoder(r.Body)
defer r.Body.Close()
var not FluxNotification
if err := dec.Decode(&not); err != nil {
l.Error("invalid message format in flux", "error", err)
return Notification{}, err
l.Error("invalid message format", "error", err)
return nil, err
}
obj := not.InvolvedObject.String()
if not.Reason == "ReconciliationSucceeded" {
// Filter out spammy ReconciliationSucceeded notification
return Notification{}, nil
if ok := f.reconciliations[obj]; !ok {
// Filter out spammy ReconciliationSucceeded notification
return nil, nil
}
// we will print the object so skip it next time it spam
f.reconciliations[obj] = false
} else {
// object has been modified, we can print it next time
f.reconciliations[obj] = true
}
title := fmt.Sprintf("[%s] %s %s/%s.%s", not.Severity, not.Reason,
strings.ToLower(not.InvolvedObject.Kind), not.InvolvedObject.Namespace, not.InvolvedObject.Name)
title := fmt.Sprintf("[%s] %s %s", not.Severity, not.Reason, obj)
body := not.Message + "\n\n**revision**\n" + not.Metadata.Revision
l.Debug("flux notification", slog.Group("notification",
slog.String("title", title),
slog.String("body", body)))
return Notification{
return []Notification{{
Title: title,
Body: body,
IsMarkdown: true,
}, nil
}}, nil
}

View file

@ -23,3 +23,12 @@ handler "/flux" {
# type "alertmanager"
# topic "/infra"
# }
# Handle discord type messages. This is meant for
# webhook that doesn't support generic one's.
# Instead, we convert discord messages to ntfy message.
# See: https://discord.com/developers/docs/resources/channel#message-object
handler "/discord-like" {
type "discord_embed" # handle message with `embeds` content
topic "discord-like"
}

View file

@ -11,12 +11,18 @@ type HandlerType int
const (
HandlerFlux HandlerType = iota + 1
HandlerDiscordEmbed
HandlerAlertmanager
)
func (h HandlerType) String() string {
switch h {
case HandlerFlux:
return "flux"
case HandlerDiscordEmbed:
return "discord_embed"
case HandlerAlertmanager:
return "alertmanager"
}
panic("unreachable")
}
@ -148,6 +154,10 @@ func readHandlerType(d *scfg.Directive) (HandlerType, error) {
switch ty {
case "flux":
return HandlerFlux, nil
case "discord_embed":
return HandlerDiscordEmbed, nil
case "alertmanager":
return HandlerAlertmanager, nil
default:
return 0, fmt.Errorf("invalid handler type %q", ty)
}

2
go.mod
View file

@ -1,5 +1,5 @@
module forge.babariviere.com/babariviere/ntfy-bridge
go 1.20
go 1.21
require git.sr.ht/~emersion/go-scfg v0.0.0-20230828131541-76adf4aeafd7

View file

@ -6,17 +6,27 @@ metadata:
data:
config.scfg: |
http-address 0.0.0.0:8080
log-level info
log-level debug
log-format text
ntfy {
server http://ntfy:80
server http://ntfy-http:80
}
handler "/flux" {
type "flux"
topic "flux"
}
handler "/forgejo" {
type "discord_embed"
topic "forgejo"
}
handler "/alerts" {
type "alertmanager"
topic "infra"
}
---
apiVersion: apps/v1
kind: Deployment
@ -54,6 +64,6 @@ spec:
ports:
- port: 8080
name: http
type: LoadBalancer
type: ClusterIP
selector:
app: bridge

View file

@ -13,7 +13,7 @@ kind: Secret
metadata:
name: ntfy-bridge-dev-address
stringData:
address: "http://bridge.ntfy-bridge-dev.svc.cluster.local:8080/flux"
address: "http://bridge:8080/flux"
---
apiVersion: notification.toolkit.fluxcd.io/v1beta2
kind: Alert

11
main.go
View file

@ -74,11 +74,18 @@ func main() {
switch handler.Type {
case config.HandlerFlux:
h = bridge.NewFluxHandler()
case config.HandlerDiscordEmbed:
h = bridge.NewDiscordEmbedHandler()
case config.HandlerAlertmanager:
h = bridge.NewAlertmanagerHandler()
}
slog.Debug("Registering bridge", "route", route, "handler", handler.Type)
// TODO: use default topic if topic == ""
bridge := bridge.NewBridge(cfg.Ntfy.Server, handler.Topic, h)
topic := handler.Topic
if topic == "" {
topic = cfg.Ntfy.DefaultTopic
}
bridge := bridge.NewBridge(cfg.Ntfy.Server, topic, h)
if !auth.IsEmpty() {
bridge.WithAuth(auth)
}

View file

@ -1,4 +1,4 @@
FROM alpine:3.18 as alpine
FROM alpine:3.19 as alpine
RUN apk add -U --no-cache ca-certificates
FROM scratch

View file

@ -2,6 +2,9 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":dependencyDashboard"
":dependencyDashboard",
":automergeMinor",
":automergePatch",
":maintainLockFilesWeekly"
]
}

View file

@ -6,6 +6,7 @@ build:
artifacts:
- image: forge.babariviere.com/babariviere/ntfy-bridge
buildpacks:
local: {}
manifests:
kustomize:
paths: