diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..080a232 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +/.direnv +/.git +/k8s +/flake.* +/LICENSE +/dist +/renovate.json +/skaffold.yaml diff --git a/.forgejo/workflows/go.yaml b/.forgejo/workflows/go.yaml index 4927f09..2a6065a 100644 --- a/.forgejo/workflows/go.yaml +++ b/.forgejo/workflows/go.yaml @@ -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' diff --git a/Dockerfile b/Dockerfile index cca5468..20ce00b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-bookworm as builder +FROM golang:1.22-bookworm as builder WORKDIR /app diff --git a/README.md b/README.md index 54244ce..569830c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,48 @@ # ntfy-bridge -Bridge for various implementations to publish to ntfy. \ No newline at end of file +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. diff --git a/bridge/alertmanager.go b/bridge/alertmanager.go new file mode 100644 index 0000000..c1fb9a9 --- /dev/null +++ b/bridge/alertmanager.go @@ -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 +} diff --git a/bridge/bridge.go b/bridge/bridge.go index f9842bc..0075236 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -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) } diff --git a/bridge/discord.go b/bridge/discord.go new file mode 100644 index 0000000..f2d423f --- /dev/null +++ b/bridge/discord.go @@ -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(¬); 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 +} diff --git a/bridge/flux.go b/bridge/flux.go index f66287f..8f9bc9f 100644 --- a/bridge/flux.go +++ b/bridge/flux.go @@ -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(¬); 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 } diff --git a/config.example.scfg b/config.example.scfg index 9437d2a..78dff6f 100644 --- a/config.example.scfg +++ b/config.example.scfg @@ -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" +} diff --git a/config/config.go b/config/config.go index 2b832f4..9e86006 100644 --- a/config/config.go +++ b/config/config.go @@ -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) } diff --git a/go.mod b/go.mod index 9fdda8d..ae3f91e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/k8s/bridge.yaml b/k8s/bridge.yaml index 68be542..915188d 100644 --- a/k8s/bridge.yaml +++ b/k8s/bridge.yaml @@ -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 diff --git a/k8s/flux.yaml b/k8s/flux.yaml index f43fec9..294c7d4 100644 --- a/k8s/flux.yaml +++ b/k8s/flux.yaml @@ -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 diff --git a/main.go b/main.go index a07caba..0846dd7 100644 --- a/main.go +++ b/main.go @@ -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) } diff --git a/release/Dockerfile b/release/Dockerfile index 741d5ea..73886c5 100644 --- a/release/Dockerfile +++ b/release/Dockerfile @@ -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 diff --git a/renovate.json b/renovate.json index 9a3152d..5c8b953 100644 --- a/renovate.json +++ b/renovate.json @@ -2,6 +2,9 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", - ":dependencyDashboard" + ":dependencyDashboard", + ":automergeMinor", + ":automergePatch", + ":maintainLockFilesWeekly" ] } diff --git a/skaffold.yaml b/skaffold.yaml index 22c9473..f85d4b3 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -6,6 +6,7 @@ build: artifacts: - image: forge.babariviere.com/babariviere/ntfy-bridge buildpacks: + local: {} manifests: kustomize: paths: