Compare commits
40 commits
Author | SHA1 | Date | |
---|---|---|---|
|
18612c7eff | ||
|
f8a25d4335 | ||
|
7384d97afb | ||
|
45bf45902c | ||
a502442913 | |||
dd38badc6e | |||
c1f23220fa | |||
773555491a | |||
1bc747c18b | |||
a428a61fc3 | |||
0ef9170f4e | |||
b73a80b4fa | |||
c832ea4645 | |||
64e91cb2d4 | |||
ac2940ccfe | |||
ea22fa5569 | |||
1b48840c8b | |||
8c3cd0f286 | |||
377cb91c67 | |||
ec3c0cfedd | |||
917d7571f2 | |||
9f303dcb89 | |||
6b035f4deb | |||
|
4234eee583 | ||
f2b57e4c99 | |||
d56873fc5d | |||
eef59c479c | |||
d63da674fd | |||
63b5b254c2 | |||
fccbb9ac06 | |||
91479478b9 | |||
b69e3d4ef3 | |||
2707cc5535 | |||
30581f132c | |||
2cc7659737 | |||
98563d75b5 | |||
0d7b4655e4 | |||
ad9bb5994e | |||
4ed2de61b5 | |||
b7c3e22875 |
22 changed files with 651 additions and 48 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/.direnv
|
||||||
|
/.git
|
||||||
|
/k8s
|
||||||
|
/flake.*
|
||||||
|
/LICENSE
|
||||||
|
/dist
|
||||||
|
/renovate.json
|
||||||
|
/skaffold.yaml
|
28
.forgejo/workflows/go.yaml
Normal file
28
.forgejo/workflows/go.yaml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# This workflow will build a golang project
|
||||||
|
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
||||||
|
|
||||||
|
name: Go
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.21'
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build -v ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test -v ./...
|
|
@ -35,6 +35,20 @@ changelog:
|
||||||
- '^docs:'
|
- '^docs:'
|
||||||
- '^test:'
|
- '^test:'
|
||||||
|
|
||||||
|
dockers:
|
||||||
|
- image_templates:
|
||||||
|
- "forge.babariviere.com/babariviere/ntfy-bridge:{{ .Tag }}"
|
||||||
|
- "forge.babariviere.com/babariviere/ntfy-bridge:{{ .Major }}"
|
||||||
|
- "forge.babariviere.com/babariviere/ntfy-bridge:{{ .Major }}.{{ .Minor }}"
|
||||||
|
- "forge.babariviere.com/babariviere/ntfy-bridge:latest"
|
||||||
|
dockerfile: release/Dockerfile
|
||||||
|
|
||||||
|
docker_manifests:
|
||||||
|
- name_template: "forge.babariviere.com/babariviere/ntfy-bridge:{{ .Tag }}"
|
||||||
|
- name_template: "forge.babariviere.com/babariviere/ntfy-bridge:{{ .Major }}"
|
||||||
|
- name_template: "forge.babariviere.com/babariviere/ntfy-bridge:{{ .Major }}.{{ .Minor }}"
|
||||||
|
- name_template: "forge.babariviere.com/babariviere/ntfy-bridge:latest"
|
||||||
|
|
||||||
gitea_urls:
|
gitea_urls:
|
||||||
api: https://forge.babariviere.com/api/v1
|
api: https://forge.babariviere.com/api/v1
|
||||||
download: https://forge.babariviere.com
|
download: https://forge.babariviere.com
|
||||||
|
|
19
Dockerfile
Normal file
19
Dockerfile
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
FROM golang:1.22-bookworm as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.* ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
RUN go build -v -o ntfy-bridge
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||||
|
ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/ntfy-bridge /app/ntfy-bridge
|
||||||
|
|
||||||
|
CMD ["/app/ntfy-bridge"]
|
47
README.md
47
README.md
|
@ -1,3 +1,48 @@
|
||||||
# ntfy-bridge
|
# 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
70
bridge/alertmanager.go
Normal 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
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -12,7 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler interface {
|
type Handler interface {
|
||||||
FormatNotification(r io.Reader) (Notification, error)
|
ProduceNotifications(r *http.Request) ([]Notification, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotificationError struct {
|
type NotificationError struct {
|
||||||
|
@ -20,17 +19,47 @@ type NotificationError struct {
|
||||||
Error string `json:"error"`
|
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 {
|
type Notification struct {
|
||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
Priority int
|
Priority int
|
||||||
Tags []string
|
Tags []string
|
||||||
|
Actions []NotificationAction
|
||||||
IsMarkdown bool
|
IsMarkdown bool
|
||||||
|
|
||||||
topic string
|
topic string
|
||||||
auth Auth
|
auth Auth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n Notification) IsEmpty() bool {
|
||||||
|
return n.Title == "" && n.Body == ""
|
||||||
|
}
|
||||||
|
|
||||||
func (n Notification) Send(base string) error {
|
func (n Notification) Send(base string) error {
|
||||||
req, err := http.NewRequest("POST", base+"/"+n.topic, strings.NewReader(n.Body))
|
req, err := http.NewRequest("POST", base+"/"+n.topic, strings.NewReader(n.Body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -63,6 +92,15 @@ func (n Notification) Send(base string) error {
|
||||||
req.Header.Set("Authorization", n.auth.bearer())
|
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)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -124,24 +162,30 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
not, err := b.h.FormatNotification(r.Body)
|
nots, err := b.h.ProduceNotifications(r)
|
||||||
defer r.Body.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to format notification")
|
slog.Error("failed to format notification")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
not.topic = b.topic
|
if len(nots) == 0 {
|
||||||
not.auth = b.auth
|
slog.Debug("no notification produced")
|
||||||
if err = not.Send(b.baseURL); err != nil {
|
w.WriteHeader(http.StatusNoContent)
|
||||||
slog.Error("unable to send notification", "error", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("notification sent with success")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
slog.Debug("notifications sent with success", "sent", len(nots))
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
74
bridge/discord.go
Normal file
74
bridge/discord.go
Normal 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(¬); 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
|
||||||
|
}
|
|
@ -3,57 +3,85 @@ package bridge
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FluxNotification struct {
|
type fluxInvolvedObject struct {
|
||||||
InvolvedObject struct {
|
Kind string `json:"kind"`
|
||||||
APIVersion string `json:"apiVersion"`
|
Namespace string `json:"namespace"`
|
||||||
Kind string `json:"kind"`
|
Name string `json:"name"`
|
||||||
Name string `json:"name"`
|
UID string `json:"uid"`
|
||||||
Namespace string `json:"namespace"`
|
APIVersion string `json:"apiVersion"`
|
||||||
UID string `json:"uid"`
|
ResourceVersion string `json:"resourceVersion"`
|
||||||
} `json:"involvedObject"`
|
|
||||||
Metadata struct {
|
|
||||||
KustomizeToolkitFluxcdIoRevision string `json:"kustomize.toolkit.fluxcd.io/revision"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
ReportingController string `json:"reportingController"`
|
|
||||||
ReportingInstance string `json:"reportingInstance"`
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FluxHandler struct{}
|
func (f fluxInvolvedObject) String() string {
|
||||||
|
return strings.ToLower(f.Kind) + "/" + f.Namespace + "." + f.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
type FluxNotification 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"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
ReportingController string `json:"reportingController"`
|
||||||
|
ReportingInstance string `json:"reportingInstance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FluxHandler struct {
|
||||||
|
// Register all modifications of reconciliations
|
||||||
|
reconciliations map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
func NewFluxHandler() FluxHandler {
|
func NewFluxHandler() FluxHandler {
|
||||||
return FluxHandler{}
|
return FluxHandler{
|
||||||
|
reconciliations: make(map[string]bool),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f FluxHandler) FormatNotification(r io.Reader) (Notification, error) {
|
func (f FluxHandler) ProduceNotifications(r *http.Request) ([]Notification, error) {
|
||||||
l := slog.With(slog.String("handler", "flux"))
|
l := slog.With(slog.String("handler", "flux"))
|
||||||
dec := json.NewDecoder(r)
|
dec := json.NewDecoder(r.Body)
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
var not FluxNotification
|
var not FluxNotification
|
||||||
if err := dec.Decode(¬); err != nil {
|
if err := dec.Decode(¬); err != nil {
|
||||||
l.Error("invalid message format in flux", "error", err)
|
l.Error("invalid message format", "error", err)
|
||||||
return Notification{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("[%s] %s %s/%s.%s", not.Severity, not.Reason,
|
obj := not.InvolvedObject.String()
|
||||||
strings.ToLower(not.InvolvedObject.Kind), not.InvolvedObject.Namespace, not.InvolvedObject.Name)
|
if not.Reason == "ReconciliationSucceeded" {
|
||||||
body := not.Message + "\n\n**revision**\n" + not.Metadata.KustomizeToolkitFluxcdIoRevision
|
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", not.Severity, not.Reason, obj)
|
||||||
|
body := not.Message + "\n\n**revision**\n" + not.Metadata.Revision
|
||||||
|
|
||||||
l.Debug("flux notification", slog.Group("notification",
|
l.Debug("flux notification", slog.Group("notification",
|
||||||
slog.String("title", title),
|
slog.String("title", title),
|
||||||
slog.String("body", body)))
|
slog.String("body", body)))
|
||||||
return Notification{
|
return []Notification{{
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
IsMarkdown: true,
|
IsMarkdown: true,
|
||||||
}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,10 +16,19 @@ ntfy {
|
||||||
|
|
||||||
handler "/flux" {
|
handler "/flux" {
|
||||||
type "flux"
|
type "flux"
|
||||||
topic "/flux"
|
topic "flux"
|
||||||
}
|
}
|
||||||
|
|
||||||
# handler "/alertmanager" {
|
# handler "/alertmanager" {
|
||||||
# type "alertmanager"
|
# type "alertmanager"
|
||||||
# topic "/infra"
|
# 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"
|
||||||
|
}
|
||||||
|
|
|
@ -11,12 +11,18 @@ type HandlerType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
HandlerFlux HandlerType = iota + 1
|
HandlerFlux HandlerType = iota + 1
|
||||||
|
HandlerDiscordEmbed
|
||||||
|
HandlerAlertmanager
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h HandlerType) String() string {
|
func (h HandlerType) String() string {
|
||||||
switch h {
|
switch h {
|
||||||
case HandlerFlux:
|
case HandlerFlux:
|
||||||
return "flux"
|
return "flux"
|
||||||
|
case HandlerDiscordEmbed:
|
||||||
|
return "discord_embed"
|
||||||
|
case HandlerAlertmanager:
|
||||||
|
return "alertmanager"
|
||||||
}
|
}
|
||||||
panic("unreachable")
|
panic("unreachable")
|
||||||
}
|
}
|
||||||
|
@ -148,6 +154,10 @@ func readHandlerType(d *scfg.Directive) (HandlerType, error) {
|
||||||
switch ty {
|
switch ty {
|
||||||
case "flux":
|
case "flux":
|
||||||
return HandlerFlux, nil
|
return HandlerFlux, nil
|
||||||
|
case "discord_embed":
|
||||||
|
return HandlerDiscordEmbed, nil
|
||||||
|
case "alertmanager":
|
||||||
|
return HandlerAlertmanager, nil
|
||||||
default:
|
default:
|
||||||
return 0, fmt.Errorf("invalid handler type %q", ty)
|
return 0, fmt.Errorf("invalid handler type %q", ty)
|
||||||
}
|
}
|
||||||
|
|
16
flake.nix
16
flake.nix
|
@ -40,8 +40,20 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default =
|
devShells.default = pkgs.mkShell {
|
||||||
pkgs.mkShell {buildInputs = with pkgs; [go gopls goreleaser];};
|
buildInputs = with pkgs; [
|
||||||
|
# Go tools
|
||||||
|
go
|
||||||
|
gopls
|
||||||
|
goreleaser
|
||||||
|
|
||||||
|
# Required for goreleaser
|
||||||
|
docker
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
skaffold
|
||||||
|
];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
flake = {
|
flake = {
|
||||||
};
|
};
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -1,5 +1,5 @@
|
||||||
module forge.babariviere.com/babariviere/ntfy-bridge
|
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
|
require git.sr.ht/~emersion/go-scfg v0.0.0-20230828131541-76adf4aeafd7
|
||||||
|
|
69
k8s/bridge.yaml
Normal file
69
k8s/bridge.yaml
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: bridge-config
|
||||||
|
data:
|
||||||
|
config.scfg: |
|
||||||
|
http-address 0.0.0.0:8080
|
||||||
|
log-level debug
|
||||||
|
log-format text
|
||||||
|
|
||||||
|
ntfy {
|
||||||
|
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
|
||||||
|
metadata:
|
||||||
|
name: bridge
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: bridge
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: bridge
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: bridge
|
||||||
|
image: forge.babariviere.com/babariviere/ntfy-bridge:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /etc/ntfy-bridge/config.scfg
|
||||||
|
subPath: config.scfg
|
||||||
|
name: config-volume
|
||||||
|
volumes:
|
||||||
|
- name: config-volume
|
||||||
|
configMap:
|
||||||
|
name: bridge-config
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: bridge
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
name: http
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: bridge
|
42
k8s/flux.yaml
Normal file
42
k8s/flux.yaml
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
apiVersion: notification.toolkit.fluxcd.io/v1beta2
|
||||||
|
kind: Provider
|
||||||
|
metadata:
|
||||||
|
name: ntfy-bridge-dev
|
||||||
|
spec:
|
||||||
|
type: generic
|
||||||
|
secretRef:
|
||||||
|
name: ntfy-bridge-dev-address
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ntfy-bridge-dev-address
|
||||||
|
stringData:
|
||||||
|
address: "http://bridge:8080/flux"
|
||||||
|
---
|
||||||
|
apiVersion: notification.toolkit.fluxcd.io/v1beta2
|
||||||
|
kind: Alert
|
||||||
|
metadata:
|
||||||
|
name: ntfy-bridge-dev
|
||||||
|
spec:
|
||||||
|
summary: "k3s cluster"
|
||||||
|
providerRef:
|
||||||
|
name: ntfy-bridge-dev
|
||||||
|
eventSeverity: info
|
||||||
|
eventSources:
|
||||||
|
- kind: GitRepository
|
||||||
|
name: '*'
|
||||||
|
namespace: flux-system
|
||||||
|
- kind: Kustomization
|
||||||
|
name: '*'
|
||||||
|
namespace: flux-system
|
||||||
|
- kind: HelmRepository
|
||||||
|
name: '*'
|
||||||
|
namespace: flux-system
|
||||||
|
- kind: HelmChart
|
||||||
|
name: '*'
|
||||||
|
namespace: flux-system
|
||||||
|
- kind: HelmRelease
|
||||||
|
name: '*'
|
||||||
|
namespace: flux-system
|
8
k8s/kustomization.yaml
Normal file
8
k8s/kustomization.yaml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
namespace: ntfy-bridge-dev
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- bridge.yaml
|
||||||
|
- flux.yaml
|
||||||
|
- ntfy.yaml
|
4
k8s/namespace.yaml
Normal file
4
k8s/namespace.yaml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: ntfy-bridge-dev
|
77
k8s/ntfy.yaml
Normal file
77
k8s/ntfy.yaml
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: ntfy-config
|
||||||
|
data:
|
||||||
|
server.yml: |
|
||||||
|
cache-file: "/var/cache/ntfy/cache.db"
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ntfy
|
||||||
|
labels:
|
||||||
|
app: ntfy
|
||||||
|
spec:
|
||||||
|
revisionHistoryLimit: 1
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ntfy
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ntfy
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ntfy
|
||||||
|
image: binwiederhier/ntfy:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
args: ["serve"]
|
||||||
|
env:
|
||||||
|
- name: TZ # set timezone
|
||||||
|
value: Etc/UTC
|
||||||
|
- name: NTFY_DEBUG # enable/disable debug
|
||||||
|
value: "true"
|
||||||
|
- name: NTFY_LOG_LEVEL # adjust log level
|
||||||
|
value: INFO
|
||||||
|
ports:
|
||||||
|
- name: ntfy-http
|
||||||
|
containerPort: 80
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 300Mi
|
||||||
|
requests:
|
||||||
|
cpu: 150m
|
||||||
|
memory: 150Mi
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /etc/ntfy/server.yml
|
||||||
|
subPath: server.yml
|
||||||
|
name: config-volume
|
||||||
|
- mountPath: /var/cache/ntfy
|
||||||
|
name: cache-volume #cache volume mounted to persistent volume
|
||||||
|
- mountPath: /var/lib/ntfy
|
||||||
|
name: lib-volume
|
||||||
|
volumes:
|
||||||
|
- name: config-volume
|
||||||
|
configMap:
|
||||||
|
name: ntfy-config
|
||||||
|
- name: cache-volume
|
||||||
|
emptyDir: {}
|
||||||
|
- name: lib-volume
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ntfy-http
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: ntfy
|
||||||
|
ports:
|
||||||
|
- name: ntfy-http-out
|
||||||
|
protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: ntfy-http
|
12
main.go
12
main.go
|
@ -33,7 +33,7 @@ func readConfig() (config.Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.ReadConfig("config.scfg")
|
cfg, err := readConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to read config", "error", err)
|
slog.Error("failed to read config", "error", err)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
|
@ -74,10 +74,18 @@ func main() {
|
||||||
switch handler.Type {
|
switch handler.Type {
|
||||||
case config.HandlerFlux:
|
case config.HandlerFlux:
|
||||||
h = bridge.NewFluxHandler()
|
h = bridge.NewFluxHandler()
|
||||||
|
case config.HandlerDiscordEmbed:
|
||||||
|
h = bridge.NewDiscordEmbedHandler()
|
||||||
|
case config.HandlerAlertmanager:
|
||||||
|
h = bridge.NewAlertmanagerHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("Registering bridge", "route", route, "handler", handler.Type)
|
slog.Debug("Registering bridge", "route", route, "handler", handler.Type)
|
||||||
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() {
|
if !auth.IsEmpty() {
|
||||||
bridge.WithAuth(auth)
|
bridge.WithAuth(auth)
|
||||||
}
|
}
|
||||||
|
|
8
release/Dockerfile
Normal file
8
release/Dockerfile
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
FROM alpine:3.19 as alpine
|
||||||
|
RUN apk add -U --no-cache ca-certificates
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=alpine /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
COPY ntfy-bridge /app/ntfy-bridge
|
||||||
|
ENTRYPOINT ["/app/ntfy-bridge"]
|
10
renovate.json
Normal file
10
renovate.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":dependencyDashboard",
|
||||||
|
":automergeMinor",
|
||||||
|
":automergePatch",
|
||||||
|
":maintainLockFilesWeekly"
|
||||||
|
]
|
||||||
|
}
|
16
skaffold.yaml
Normal file
16
skaffold.yaml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
apiVersion: skaffold/v4beta6
|
||||||
|
kind: Config
|
||||||
|
metadata:
|
||||||
|
name: ntfy-bridge
|
||||||
|
build:
|
||||||
|
artifacts:
|
||||||
|
- image: forge.babariviere.com/babariviere/ntfy-bridge
|
||||||
|
buildpacks:
|
||||||
|
local: {}
|
||||||
|
manifests:
|
||||||
|
kustomize:
|
||||||
|
paths:
|
||||||
|
- k8s
|
||||||
|
deploy:
|
||||||
|
kubectl:
|
||||||
|
defaultNamespace: ntfy-bridge-dev
|
Loading…
Add table
Reference in a new issue