├── .gitignore ├── .gitlab-ci.yml ├── .gitlab └── issue_templates │ ├── Bug.md │ └── Feature.md ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── _docs ├── config.toml ├── content │ ├── _index.md │ ├── gettingStarted │ │ ├── _index.md │ │ ├── configuration.md │ │ ├── docker.md │ │ ├── e2ee.md │ │ ├── grafana-legacy.md │ │ ├── grafana-unified.md │ │ ├── persistence.md │ │ └── setup.md │ └── metrics │ │ ├── _index.md │ │ ├── details.md │ │ └── grafana.md └── static │ └── img │ ├── alertExample.png │ ├── exampleGrafanaDashboard.png │ ├── grafanaLegacyAlertSetup.png │ ├── grafanaLegacyChannelSetup.png │ ├── grafanaUnifiedContactPoint.png │ ├── grafanaUnifiedDetails.png │ ├── grafanaUnifiedPolicy.png │ └── highLevelDiagram.png ├── app.go ├── cfg ├── kong.go ├── settings.go └── validate.go ├── formatter ├── message.go ├── message_test.go ├── reaction.go ├── templates.go └── util.go ├── go.mod ├── go.sum ├── matrix ├── interfaces.go └── matrix.go ├── model └── data.go ├── server ├── handler.go ├── metrics │ ├── collector.go │ ├── metrics.go │ └── metrics_test.go ├── server.go ├── util │ └── http.go ├── v0 │ ├── handler.go │ ├── handler_test.go │ ├── payload.go │ └── payload_test.go └── v1 │ ├── handler.go │ └── payload.go └── service ├── forwarder.go └── persistence.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | *.iml 4 | 5 | build/ 6 | dist/ 7 | _docs/public/ 8 | _docs/themes/ 9 | 10 | grafana-matrix-forwarder 11 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - build 4 | - release 5 | 6 | .go_template: 7 | image: golang:latest 8 | 9 | sast: 10 | stage: test 11 | 12 | include: 13 | - template: Security/SAST.gitlab-ci.yml 14 | - template: Security/Dependency-Scanning.gitlab-ci.yml 15 | 16 | format: 17 | extends: .go_template 18 | stage: test 19 | script: 20 | - make check/fmt 21 | 22 | vet: 23 | extends: .go_template 24 | stage: test 25 | allow_failure: true 26 | script: 27 | - make vet 28 | 29 | test: 30 | extends: .go_template 31 | stage: test 32 | script: 33 | - make test 34 | 35 | build: 36 | extends: .go_template 37 | stage: build 38 | script: 39 | - make build 40 | artifacts: 41 | paths: 42 | - grafana-matrix-forwarder 43 | expire_in: 1 day 44 | 45 | build pages: 46 | stage: build 47 | image: registry.gitlab.com/pages/hugo/hugo:latest 48 | script: 49 | - apk add make 50 | - make docs/downloadTheme 51 | - make docs/build 52 | artifacts: 53 | paths: 54 | - _docs/public/ 55 | 56 | pages: 57 | stage: release 58 | image: alpine 59 | only: 60 | - main 61 | script: 62 | - mv _docs/public/ public/ 63 | artifacts: 64 | paths: 65 | - public/ 66 | 67 | release: 68 | stage: release 69 | image: docker:stable 70 | services: 71 | - docker:dind 72 | variables: 73 | DOCKER_REGISTRY: $CI_REGISTRY 74 | DOCKER_USERNAME: $CI_REGISTRY_USER 75 | DOCKER_PASSWORD: $CI_REGISTRY_PASSWORD 76 | GIT_DEPTH: 0 77 | rules: 78 | - if: $CI_COMMIT_TAG =~ /^v.*$/ 79 | script: | 80 | docker run --rm --privileged \ 81 | -v $PWD:/go/src/gitlab.com/hctrdev/grafana-matrix-forwarder \ 82 | -w /go/src/gitlab.com/hctrdev/grafana-matrix-forwarder \ 83 | -v /var/run/docker.sock:/var/run/docker.sock \ 84 | -e DOCKER_USERNAME -e DOCKER_PASSWORD -e DOCKER_REGISTRY \ 85 | -e GITLAB_TOKEN \ 86 | goreleaser/goreleaser release --clean 87 | -------------------------------------------------------------------------------- /.gitlab/issue_templates/Bug.md: -------------------------------------------------------------------------------- 1 | **Describe the bug** 2 | 3 | (Clear and concise description of the bug) 4 | 5 | **Steps to reproduce** 6 | 7 | (Steps to allow reproducing the bug - Include as many details as possible) 8 | 9 | 1. (Step 1) 10 | 1. (Step 2) 11 | 1. (More steps as necessary) 12 | 13 | **Observed behaviour** 14 | 15 | (Description of the observed behaviour after executing the steps above) 16 | 17 | **Expected behaviour** 18 | 19 | (A description of the expected behaviour after executing the steps above) 20 | 21 | **Notes** 22 | 23 | (If you have any other notes or insights about this bug, please include them) 24 | 25 | /label ~"type::bug" 26 | -------------------------------------------------------------------------------- /.gitlab/issue_templates/Feature.md: -------------------------------------------------------------------------------- 1 | **Requirement** 2 | 3 | (Exact description of the desired functionality) 4 | 5 | **Value proposition** 6 | 7 | (Description of what value arises from implementing this solution e.g. what problem does it solve) 8 | 9 | **Design ideas** 10 | 11 | (Add any thoughts or ideas on how to implement this functionality) 12 | 13 | /label ~"type::feature" 14 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: grafana-matrix-forwarder 2 | builds: 3 | - env: [CGO_ENABLED=0] 4 | binary: grafana-matrix-forwarder 5 | goos: 6 | - linux 7 | - windows 8 | - darwin 9 | goarch: 10 | - amd64 11 | - "386" 12 | - arm 13 | - arm64 14 | goarm: 15 | - "6" 16 | - "7" 17 | 18 | dockers: 19 | - image_templates: 20 | - "registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}-amd64" 21 | - "registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}.{{ .Minor }}-amd64" 22 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Version }}-amd64' 23 | - "registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest-amd64" 24 | use: buildx 25 | build_flag_templates: 26 | - "--pull" 27 | - "--platform=linux/amd64" 28 | - --label=org.opencontainers.image.title={{ .ProjectName }} 29 | - --label=org.opencontainers.image.description={{ .ProjectName }} 30 | - --label=org.opencontainers.image.url=https://gitlab.com/hctrdev/grafana-matrix-forwarder 31 | - --label=org.opencontainers.image.source=https://gitlab.com/hctrdev/grafana-matrix-forwarder 32 | - --label=org.opencontainers.image.version={{ .Version }} 33 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 34 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 35 | - --label=org.opencontainers.image.licenses=MIT 36 | - image_templates: 37 | - "registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}-arm64" 38 | - "registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}.{{ .Minor }}-arm64" 39 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Version }}-arm64' 40 | - "registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest-arm64" 41 | use: buildx 42 | build_flag_templates: 43 | - "--pull" 44 | - "--platform=linux/arm64" 45 | - --label=org.opencontainers.image.title={{ .ProjectName }} 46 | - --label=org.opencontainers.image.description={{ .ProjectName }} 47 | - --label=org.opencontainers.image.url=https://gitlab.com/hctrdev/grafana-matrix-forwarder 48 | - --label=org.opencontainers.image.source=https://gitlab.com/hctrdev/grafana-matrix-forwarder 49 | - --label=org.opencontainers.image.version={{ .Version }} 50 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 51 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 52 | - --label=org.opencontainers.image.licenses=MIT 53 | goarch: arm64 54 | 55 | docker_manifests: 56 | - name_template: 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}' 57 | image_templates: 58 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}-amd64' 59 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}-arm64' 60 | 61 | - name_template: 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}.{{ .Minor }}' 62 | image_templates: 63 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}.{{ .Minor }}-amd64' 64 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Major }}.{{ .Minor }}-arm64' 65 | 66 | - name_template: 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Version }}' 67 | image_templates: 68 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Version }}-amd64' 69 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:{{ .Version }}-arm64' 70 | 71 | - name_template: 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest' 72 | image_templates: 73 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest-amd64' 74 | - 'registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest-arm64' 75 | 76 | changelog: 77 | groups: 78 | - title: "⛔ Breaking Changes" 79 | regexp: '^.*?!:.+$' 80 | order: 0 81 | - title: "🎉 Features" 82 | regexp: '^.*?feat(\(\w+\))??:.+$' 83 | order: 1 84 | - title: "🐛 Fixes" 85 | regexp: '^.*?fix(\(\w+\))??:.+$' 86 | order: 2 87 | - title: "📑 Other" 88 | order: 999 89 | filters: 90 | exclude: 91 | - "^Merge" 92 | - "^merge" 93 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | # Create main app folder to run from 4 | WORKDIR /app 5 | 6 | # Copy compiled binary to release image 7 | # (must build the binary before running docker build) 8 | COPY grafana-matrix-forwarder /app/grafana-matrix-forwarder 9 | 10 | ENTRYPOINT ["/app/grafana-matrix-forwarder"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hector 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # List make commands 2 | .PHONY: ls 3 | ls: 4 | cat Makefile | grep "^[a-zA-Z#].*" | cut -d ":" -f 1 | sed s';#;\n#;'g 5 | 6 | # Download dependencies 7 | .PHONY: download 8 | download: 9 | go mod download 10 | 11 | # Update project dependencies 12 | .PHONY: update 13 | update: 14 | go get -u 15 | go mod download 16 | go mod tidy 17 | 18 | # Run project tests 19 | .PHONY: test 20 | test: download 21 | go test ./... -v -race 22 | 23 | # Look for "suspicious constructs" in source code 24 | .PHONY: vet 25 | vet: download 26 | go vet ./... 27 | 28 | # Format code 29 | .PHONY: fmt 30 | fmt: download 31 | go mod tidy 32 | go fmt ./... 33 | 34 | # Check for unformatted go code 35 | .PHONY: check/fmt 36 | check/fmt: download 37 | test -z $(shell gofmt -l .) 38 | 39 | # Build project 40 | .PHONY: build 41 | build: 42 | CGO_ENABLED=0 go build \ 43 | -ldflags "\ 44 | -X main.version=${shell git describe --tags} \ 45 | -X main.commit=${shell git rev-parse HEAD} \ 46 | -X main.date=${shell date --iso-8601=seconds} \ 47 | -X main.builtBy=manual \ 48 | " \ 49 | -o grafana-matrix-forwarder \ 50 | app.go 51 | 52 | # Build project docker container 53 | .PHONY: build/docker 54 | build/docker: build 55 | docker build -t grafana-matrix-forwarder . 56 | 57 | # Download theme used for docs site 58 | .PHONY: docs/downloadTheme 59 | docs/downloadTheme: 60 | wget -O geekdoc.tar.gz https://github.com/thegeeklab/hugo-geekdoc/releases/download/v0.10.1/hugo-geekdoc.tar.gz 61 | mkdir -p _docs/themes/hugo-geekdoc 62 | tar -xf geekdoc.tar.gz -C _docs/themes/hugo-geekdoc/ 63 | 64 | # Build the docs site 65 | .PHONY: docs/build 66 | docs/build: 67 | cd _docs/ && hugo 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grafana to Matrix Forwarder 2 | *Forward alerts from [Grafana](https://grafana.com) to a [Matrix](https://matrix.org) chat room.* 3 | 4 | [![documentation](https://img.shields.io/badge/docs-latest-orange)](https://hctrdev.gitlab.io/grafana-matrix-forwarder/) 5 | [![pipeline status](https://gitlab.com/hctrdev/grafana-matrix-forwarder/badges/main/pipeline.svg)](https://gitlab.com/hctrdev/grafana-matrix-forwarder/-/commits/main) [![Go Report Card](https://goreportcard.com/badge/gitlab.com/hctrdev/grafana-matrix-forwarder)](https://goreportcard.com/report/gitlab.com/hctrdev/grafana-matrix-forwarder) 6 | 7 | --- 8 | 9 | This project provides a simple way to forward alerts generated by Grafana to a Matrix chat room. 10 | 11 | Define a Grafana webhook alert channel that targets an instance of this application. 12 | This tool will convert the incoming webhook to a Matrix message and send it on to a specific chat room. 13 | 14 | ![screenshot of matrix alert message](_docs/static/img/alertExample.png) 15 | 16 | ## 1. Features 17 | 18 | * 📦 **Portable** 19 | * As a single binary the tool is easy to run in any environment 20 | * 📎 **Simple** 21 | * No config files, all required parameters provided on startup 22 | * 🪁 **Flexible** 23 | * Support multiple grafana alert channels to multiple matrix rooms 24 | * 📈 **Monitorable** 25 | * Export metrics to track successful and failed forwards 26 | 27 | ## 2. How to use 28 | *This applies to unified alerts, check the [documentation](https://hctrdev.gitlab.io/grafana-matrix-forwarder/gettingStarted/grafana-legacy/) for legacy alerts* 29 | 30 | **Step 1** 31 | 32 | Run the forwarder by providing a matrix account to send messages from. 33 | 34 | ``` 35 | $ ./grafana-matrix-forwarder --user @userId:matrix.org --password xxx --homeserver matrix.org 36 | ``` 37 | 38 | **Step 2** 39 | 40 | Add a new **Contact Point** in Grafana with the **POST webhook** type. Use the following URL: 41 | ``` 42 | http://:6000/api/v1/unified?roomId= 43 | ``` 44 | 45 | *Replace with the server ID and matrix room ID.* 46 | 47 | **Step 3** 48 | 49 | Add a new Notification Policy in Grafana to send alerts to the new contact point. 50 | 51 | *If you use the root policy, all alerts will be sent to that contact point* 52 | 53 | **Step 4** 54 | 55 | Create alert rules in grafana and add text to the a "Summary" field to be displayed in the Matrix message. 56 | 57 | ## 3. Docker 58 | 59 | An official docker image is available on the Gitlab container registry. 60 | Use it by pulling the following image: 61 | 62 | ``` 63 | registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest 64 | ``` 65 | 66 | Example run command: 67 | ``` 68 | docker run -d \ 69 | --name "grafana-matrix-forwarder" \ 70 | -e GMF_MATRIX_USER=@user:matrix.org \ 71 | -e GMF_MATRIX_PASSWORD=password \ 72 | -e GMF_MATRIX_HOMESERVER=matrix.org \ 73 | registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest 74 | ``` 75 | 76 | Read the [documentation](https://hctrdev.gitlab.io/grafana-matrix-forwarder/) for more detail on using Docker. 77 | 78 | ## Thanks 79 | 80 | Made possible by the [maunium.net/go/mautrix](https://maunium.net/go/mautrix/) library and all the contributors to the [matrix.org](https://matrix.org) protocol. 81 | -------------------------------------------------------------------------------- /_docs/config.toml: -------------------------------------------------------------------------------- 1 | baseURL = "https://hctrdev.gitlab.io/grafana-matrix-forwarder" 2 | title = "Grafana to Matrix Forwarder" 3 | theme = "hugo-geekdoc" 4 | 5 | # Geekdoc required configuration 6 | pygmentsUseClasses = true 7 | pygmentsCodeFences = true 8 | disablePathToLower = true 9 | 10 | # Needed for mermaid shortcodes 11 | [markup] 12 | [markup.goldmark.renderer] 13 | # Needed for mermaid shortcode 14 | unsafe = true 15 | [markup.tableOfContents] 16 | startLevel = 1 17 | endLevel = 9 18 | 19 | [params] 20 | geekdocRepo = "https://gitlab.com/hctrdev/grafana-matrix-forwarder" 21 | geekdocEditPath = "-/edit/main/docs/content" 22 | -------------------------------------------------------------------------------- /_docs/content/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Documentation 3 | --- 4 | 5 | 6 | 7 | 8 | [![Project](https://img.shields.io/badge/project-gitlab-brightgreen?style=flat&logo=gitlab)](https://gitlab.com/hctrdev/grafana-matrix-forwarder/) 9 | [![Build Status](https://gitlab.com/hctrdev/grafana-matrix-forwarder/badges/main/pipeline.svg)](https://gitlab.com/hctrdev/grafana-matrix-forwarder/commits/main) 10 | [![Go Report Card](https://goreportcard.com/badge/gitlab.com/hctrdev/grafana-matrix-forwarder)](https://goreportcard.com/report/gitlab.com/hctrdev/grafana-matrix-forwarder) 11 | [![License: MIT](https://img.shields.io/badge/license-MIT-brightgreen)](https://gitlab.com/hctrdev/csharp-performance-recorder/-/blob/main/LICENSE) 12 | 13 | 14 | # Grafana to Matrix Forwarder 15 | *Forward alerts from [Grafana](https://grafana.com) to a [Matrix](https://matrix.org) chat room.* 16 | 17 | ![screenshot of matrix alert message](img/alertExample.png) 18 | 19 | * 📦 **Portable** 20 | * As a single binary the tool is easy to run in any environment 21 | * 📎 **Simple** 22 | * No config files, all required parameters provided on startup 23 | * 🪁 **Flexible** 24 | * Support multiple grafana alert channels to multiple matrix rooms 25 | * 📈 **Monitorable** 26 | * Export metrics to track successful and failed forwards 27 | 28 | --- 29 | 30 | *Made possible by the [maunium.net/go/mautrix](https://maunium.net/go/mautrix/) library and all the contributors to the [matrix.org](https://matrix.org) protocol.* 31 | -------------------------------------------------------------------------------- /_docs/content/gettingStarted/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | weight: -20 4 | --- 5 | 6 | Define a Grafana webhook alert channel that targets an instance of this application. 7 | This tool will convert the incoming webhook to a Matrix message and send it on to a specific chat room. 8 | 9 | ![diagram of how this projects integrates between Grafana and Matrix](img/highLevelDiagram.png) 10 | -------------------------------------------------------------------------------- /_docs/content/gettingStarted/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration 3 | weight: 30 4 | --- 5 | 6 | The tool can be configured with either CLI flags and environment variables. CLI flags will take priority. 7 | 8 | ## CLI Flags 9 | 10 | ``` 11 | Usage: grafana-matrix-forwarder 12 | 13 | Forward alerts from Grafana to a Matrix room 14 | 15 | Flags: 16 | -h, --help Show context-sensitive help. 17 | -v, --version Show version info and exit 18 | 19 | 🔌 Server 20 | Configure the server used to receive alerts from Grafana 21 | 22 | --host="0.0.0.0" Host address the server connects to ($GMF_SERVER_HOST) 23 | --port=6000 Port to run the webserver on ($GMF_SERVER_PORT) 24 | --auth.scheme=STRING Set the scheme for required authentication - valid options are: bearer ($GMF_AUTH_SCHEME) 25 | --auth.credentials=STRING Credentials required to forward alerts ($GMF_AUTH_CREDENTIALS) 26 | 27 | 💬 Matrix 28 | How to connect to Matrix to forward alerts 29 | 30 | --homeserver="matrix.org" URL of the homeserver to connect to ($GMF_MATRIX_HOMESERVER) 31 | --user=STRING Username used to login to matrix ($GMF_MATRIX_USER) 32 | --password=STRING Password used to login to matrix ($GMF_MATRIX_PASSWORD) 33 | --token=STRING Auth token used to authenticate with matrix ($GMF_MATRIX_TOKEN) 34 | 35 | ❗ Alerts 36 | Configuration for the alerts themselves 37 | 38 | --resolveMode="message" Set how to handle resolved alerts - valid options are: message, reaction, reply ($GMF_RESOLVE_MODE) 39 | --[no-]persistAlertMap Persist the internal map between grafana alerts and matrix messages - this is used to support resolving alerts using replies ($GMF_PERSIST_ALERT_MAP) 40 | --metricRounding=3 Round metric values to the specified decimal places ($GMF_METRIC_ROUNDING) 41 | 42 | ❔ Debug 43 | Options to help debugging issues 44 | 45 | --logPayload Print the contents of every alert request received from grafana ($GMF_LOG_PAYLOAD) 46 | 47 | 🔻 Deprecated 48 | Flags that have been deprecated and should no longer be used 49 | 50 | --env No longer has any effect 51 | ``` 52 | 53 | ## Environment Variables 54 | 55 | The following environment variables should be set to configure how the forwarder container runs. 56 | These environment variables map directly to the CLI parameters of the application. 57 | 58 | | Name | Required | Description | 59 | |------|----------|-------------| 60 | | `GMF_MATRIX_USER` | X | Username used to login to matrix | 61 | | `GMF_MATRIX_PASSWORD` | X | Password used to login to matrix | 62 | | `GMF_MATRIX_TOKEN` | X | Token to be used to authenticate against the matrix server | 63 | | `GMF_MATRIX_HOMESERVER` | X | URL of the matrix homeserver to connect to | 64 | | `GMF_SERVER_HOST` | | Host address the server connects to (defaults to "0.0.0.0") | 65 | | `GMF_SERVER_PORT` | | Port to run the webserver on (default 6000) | 66 | | `GMF_RESOLVE_MODE` | | Set how to handle resolved alerts - valid options are: 'message', 'reaction', and 'reply' | 67 | | `GMF_LOG_PAYLOAD` | | Set to any value to print the contents of every alert request received from grafana (disabled if blank or set to "false") | 68 | | `GMF_METRIC_ROUNDING` | | Set the number of decimal places to round metric values to (-1 to disable all rounding) | 69 | | `GMF_PERSIST_ALERT_MAP` | | Persist the internal map between grafana alerts and matrix messages - this is used to support resolving alerts using replies (defaults to "true") | 70 | 71 | {{< hint info >}} 72 | Either the token or the password must be set, not both. 73 | {{< /hint >}} 74 | 75 | ## Getting a Matrix Token 76 | 77 | To get a matrix token you can run the following command: 78 | 79 | ``` 80 | curl -XPOST -d '{"type": "m.login.password", "identifier": {"user": "myusername", "type": "m.id.user"}, "password": "mypassword"}' "https://matrix.org/_matrix/client/r0/login" 81 | ``` 82 | 83 | {{< hint info >}} 84 | Don't forget to change the `user`, `password`, and homeserver URL for the account you want to use. 85 | {{< /hint >}} 86 | 87 | The output will look something like: 88 | 89 | ``` 90 | {"user_id":"@mysername:matrix.org","access_token":"xxxxx","home_server":"matrix.org","device_id":"something","well_known":{"m.homeserver":{"base_url":"https://matrix-client.matrix.org/"}}} 91 | ``` 92 | 93 | Then just copy the value for `access_token` and use it. 94 | -------------------------------------------------------------------------------- /_docs/content/gettingStarted/docker.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Docker 3 | weight: 40 4 | --- 5 | 6 | The tool can be run inside a docker container if desired. 7 | 8 | The docker image can be pulled from the Gitlab registry for this project 9 | 10 | ``` 11 | registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest 12 | ``` 13 | 14 | Check the [registry page](https://gitlab.com/hctrdev/grafana-matrix-forwarder/container_registry/1616723) for all available tags. 15 | 16 | {{< tabs "dockerExamples" >}} 17 | {{< tab "Docker run" >}} 18 | 19 | {{< highlight bash "linenos=table" >}} 20 | docker run -d \ 21 | --name "grafana-matrix-forwarder" \ 22 | -e GMF_MATRIX_USER=@user:matrix.org \ 23 | -e GMF_MATRIX_PASSWORD=password \ 24 | -e GMF_MATRIX_HOMESERVER=matrix.org \ 25 | registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest 26 | {{< /highlight >}} 27 | 28 | {{< /tab >}} 29 | {{< tab "Docker Compose" >}} 30 | 31 | {{< highlight yaml "linenos=table" >}} 32 | version: "2" 33 | services: 34 | forwarder: 35 | image: registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest 36 | environment: 37 | - GMF_MATRIX_USER=@user:matrix.org 38 | - GMF_MATRIX_PASSWORD=password 39 | - GMF_MATRIX_HOMESERVER=matrix.org 40 | ports: 41 | - "6000:6000" 42 | {{< /highlight >}} 43 | 44 | {{< /tab >}} 45 | {{< /tabs >}} 46 | -------------------------------------------------------------------------------- /_docs/content/gettingStarted/e2ee.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: End to End Encryption 3 | weight: 60 4 | --- 5 | 6 | This tool does not natively support sending alerts to matrix rooms with encryption enabled. 7 | 8 | However, encrypted rooms are supported by using [pantalaimon](https://github.com/matrix-org/pantalaimon) to act as a reverse proxy that handles the encryption. 9 | Information on setting up pantalaimon can be found on the project's Github page. 10 | 11 | The grafana alert forwarder can be configured to send messages through the pantalaimon proxy server by setting the `GMF_MATRIX_HOMESERVER` environment variable (or `-homeserver` cli argument) to point at your pantalaimon instance. 12 | 13 | ## Example 14 | 15 | The following docker compose file demonstrates how to run both the forwarder and pantalaimon together. 16 | 17 | {{< highlight yaml "linenos=table" >}} 18 | version: "2" 19 | services: 20 | pantalaimon: 21 | image: matrixdotorg/pantalaimon 22 | restart: unless-stopped 23 | volumes: 24 | - /docker/pantalaimon:/data 25 | 26 | forwarder: 27 | image: registry.gitlab.com/hctrdev/grafana-matrix-forwarder:latest 28 | restart: unless-stopped 29 | environment: 30 | - GMF_MATRIX_USER=@user:matrix.org 31 | - GMF_MATRIX_PASSWORD=pw 32 | - GMF_MATRIX_HOMESERVER=http://pantalaimon:8080 33 | ports: 34 | - 6000:6000 35 | {{< /highlight >}} 36 | 37 | {{< hint info >}} 38 | NOTE: This assumes that pantalaimon has been configured to run on port `8080` 39 | {{< /hint >}} 40 | -------------------------------------------------------------------------------- /_docs/content/gettingStarted/grafana-legacy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Grafana: Legacy' 3 | weight: 50 4 | --- 5 | 6 | {{< hint warning >}} 7 | This is the old alert system used by Grafana. It has now been deprecated in favour of a [unified]({{< ref "grafana-unified.md" >}}) alert system. 8 | {{< /hint >}} 9 | 10 | ## Step 1 11 | 12 | Add a new **POST webhook** alert channel with the following target URL: `http://:6000/api/v1/standard?roomId=` 13 | 14 | *Replace with the actual server IP and matrix room ID.* 15 | 16 | {{< hint info >}} 17 | NOTE: multiple `roomId` parameters can be provided to forward the alert to multiple rooms at once 18 | {{< /hint >}} 19 | 20 | ![screenshot of grafana channel setup](img/grafanaLegacyChannelSetup.png) 21 | 22 | ## Step 2 23 | 24 | Setup alerts in grafana that are sent to the new alert channel. 25 | 26 | ![screenshot of grafana alert setup](img/grafanaLegacyAlertSetup.png) 27 | -------------------------------------------------------------------------------- /_docs/content/gettingStarted/grafana-unified.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Grafana: Unified' 3 | weight: 50 4 | --- 5 | 6 | ## Step 1 7 | 8 | Add a new **Contact Point** of type **Webhook** with the following target URL: `http://:6000/api/v1/unified?roomId=` 9 | 10 | *Replace with the actual server IP and matrix room ID.* 11 | 12 | {{< hint info >}} 13 | NOTE: multiple `roomId` parameters can be provided to forward the alert to multiple rooms at once 14 | {{< /hint >}} 15 | 16 | ![screenshot of grafana contact point setup](img/grafanaUnifiedContactPoint.png) 17 | 18 | ## Step 2 19 | 20 | Set up a new **Notification Policy** to forward alerts to the new contact point. 21 | 22 | The screenshot below shows it set as the *root policy* - the default for all alerts. 23 | 24 | ![screenshot of grafana notification policy setup](img/grafanaUnifiedPolicy.png) 25 | 26 | ## Step 3 27 | 28 | Set a **Summary** field to the alert details to have it shown in the Matrix message. 29 | 30 | ![screenshot of grafana alert details](img/grafanaUnifiedDetails.png) 31 | -------------------------------------------------------------------------------- /_docs/content/gettingStarted/persistence.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Persistence 3 | weight: 70 4 | --- 5 | 6 | By default, the tool will create a `grafanaToMatrixMap.json` file on each forwarded alert. This file contains a map from grafana alert to matrix message. 7 | 8 | This file is only a copy of the tool's internal state to support restarts. It does not need to be backed up. 9 | 10 | {{< hint info >}} 11 | The creation of this file can be disabled by setting the `--persistAlertMap` CLI flag (or `GMF_PERSIST_ALERT_MAP` environment variable) to `false` on startup. 12 | {{< /hint >}} 13 | 14 | ## Technical Explanation 15 | 16 | {{< hint info >}} 17 | The following only applies when the `resolveMode` is set to `reply`. 18 | {{< /hint >}} 19 | 20 | To support resolving alerts using a reply, the tool needs to keep track of the original Matrix messages it sent for a given alert. This includes the message ID and the message content. 21 | 22 | When a new grafana alert comes in for the same ID and the `resolved` state, the tool tries to find the message it sent for the original alert (with the `alerting` state). If the tool finds a message, it can use that message to generate a reply. 23 | 24 | As the map is stored in memory, it is lost when the tool restarts. By persisting the map to a file, the internal state can be restored on startup. 25 | 26 | When the tool fails to find an entry for the grafana alert ID, it defaults to send a standard message - even when the resolve mode is `reply`. 27 | -------------------------------------------------------------------------------- /_docs/content/gettingStarted/setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Run Binary 3 | weight: 20 4 | --- 5 | 6 | The tool is compiled as a single binary, so it is simple to run as a standalone application. 7 | 8 | To run the forwarder just provide credentials to connect to a matrix account to send messages from. 9 | 10 | ``` 11 | $ ./grafana-matrix-forwarder --user @userId:matrix.org --password xxx --homeserver matrix.org 12 | ``` 13 | 14 | See the page on [configuration]({{< ref "configuration.md" >}}) to change the port the forwarder listens on. 15 | -------------------------------------------------------------------------------- /_docs/content/metrics/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Metrics 3 | weight: -10 4 | --- 5 | 6 | This tool exports prometheus-compatible metrics on the same port at the `/metrics` URL. 7 | 8 | {{< hint info >}} 9 | NOTE: All metric names include the `gmf_` prefix (grafana matrix forwarder) to make sure they are unique and make them easier to find. 10 | {{< /hint >}} 11 | 12 | ## Exposed Metrics 13 | 14 | | Metric Name | Type | Description | 15 | |--------|------|-------------| 16 | | `gmf_up` | `gauge` | Returns 1 if the service is up | 17 | | `gmf_forwards` | `gauge` | Counts the number of matrix messages sent (both successfully and with errors) | 18 | | `gmf_alerts` | `gauge` | Counts the number of grafana alerts received by type | 19 | -------------------------------------------------------------------------------- /_docs/content/metrics/details.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Details 3 | weight: 10 4 | --- 5 | 6 | ## `gmf_forwards` 7 | 8 | This metric counts the number of alerts which were forwarded on to a matrix room. It splits into three labels: 9 | 10 | | Label Name | Description | 11 | |------------|-------------| 12 | | `gmf_forwards{result="success"}` | Number of matrix messages which were forwarded successfully | 13 | | `gmf_forwards{result="error"}` | Number of matrix messages where the forwarding process failed | 14 | 15 | ## `gmf_alerts` 16 | 17 | This metric counts the number of grafana alerts received by the tool. It splits into five labels: 18 | 19 | | Label Name | Description | 20 | |------------|-------------| 21 | | `gmf_alerts{state="alerting"}` | Number of grafana alerts with the *alerting* state (i.e. the alert is firing) | 22 | | `gmf_alerts{state="no_data"}` | Number of grafana alerts with the *no_data* state (for example, when grafana failed to read data for that metric) | 23 | | `gmf_alerts{state="ok"}` | Number of grafana alerts with the *ok* state (i.e. resolved alerts) | 24 | | `gmf_alerts{state="other"}` | Number of alerts with an unknown state - check logs for details | 25 | 26 | ### Raw Output Example 27 | {{< highlight bash "linenos=table" >}} 28 | # HELP gmf_alerts Alert states being processed by the forwarder 29 | # TYPE gmf_alerts counter 30 | gmf_alerts{state="alerting"} 5 31 | gmf_alerts{state="no_data"} 1 32 | gmf_alerts{state="ok"} 3 33 | gmf_alerts{state="other"} 1 34 | # HELP gmf_forwards Successful and failed alert forwards 35 | # TYPE gmf_forwards counter 36 | gmf_forwards{result="fail"} 6 37 | gmf_forwards{result="success"} 4 38 | # HELP gmf_up Alert forwarder is up and running 39 | # TYPE gmf_up gauge 40 | gmf_up 1 41 | {{< /highlight >}} 42 | -------------------------------------------------------------------------------- /_docs/content/metrics/grafana.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Grafana 3 | weight: 20 4 | --- 5 | 6 | These metrics can be loaded into Grafana to create a dashboard and/or other alerts. This makes it possible to monitor the forwarder and send an alert of a different type if it goes down (e.g. email). 7 | 8 | ![Screenshot of an example Grafana dashboard](/img/exampleGrafanaDashboard.png) 9 | -------------------------------------------------------------------------------- /_docs/static/img/alertExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hctrdev/grafana-matrix-forwarder/a77a360503ea12c8244646753a3bf271428ddd02/_docs/static/img/alertExample.png -------------------------------------------------------------------------------- /_docs/static/img/exampleGrafanaDashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hctrdev/grafana-matrix-forwarder/a77a360503ea12c8244646753a3bf271428ddd02/_docs/static/img/exampleGrafanaDashboard.png -------------------------------------------------------------------------------- /_docs/static/img/grafanaLegacyAlertSetup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hctrdev/grafana-matrix-forwarder/a77a360503ea12c8244646753a3bf271428ddd02/_docs/static/img/grafanaLegacyAlertSetup.png -------------------------------------------------------------------------------- /_docs/static/img/grafanaLegacyChannelSetup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hctrdev/grafana-matrix-forwarder/a77a360503ea12c8244646753a3bf271428ddd02/_docs/static/img/grafanaLegacyChannelSetup.png -------------------------------------------------------------------------------- /_docs/static/img/grafanaUnifiedContactPoint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hctrdev/grafana-matrix-forwarder/a77a360503ea12c8244646753a3bf271428ddd02/_docs/static/img/grafanaUnifiedContactPoint.png -------------------------------------------------------------------------------- /_docs/static/img/grafanaUnifiedDetails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hctrdev/grafana-matrix-forwarder/a77a360503ea12c8244646753a3bf271428ddd02/_docs/static/img/grafanaUnifiedDetails.png -------------------------------------------------------------------------------- /_docs/static/img/grafanaUnifiedPolicy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hctrdev/grafana-matrix-forwarder/a77a360503ea12c8244646753a3bf271428ddd02/_docs/static/img/grafanaUnifiedPolicy.png -------------------------------------------------------------------------------- /_docs/static/img/highLevelDiagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hctrdev/grafana-matrix-forwarder/a77a360503ea12c8244646753a3bf271428ddd02/_docs/static/img/highLevelDiagram.png -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "grafana-matrix-forwarder/cfg" 7 | "grafana-matrix-forwarder/matrix" 8 | "grafana-matrix-forwarder/server" 9 | "log" 10 | "os" 11 | "os/signal" 12 | ) 13 | 14 | var ( 15 | version = "dev" 16 | commit = "none" 17 | date = "unknown" 18 | builtBy = "unknown" 19 | ) 20 | 21 | func main() { 22 | ctx, _ := listenForInterrupt() 23 | 24 | appSettings := cfg.Parse() 25 | if appSettings.VersionMode { 26 | printAppVersion() 27 | } else { 28 | err := run(ctx, appSettings) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | log.Print("done") 33 | } 34 | } 35 | 36 | func listenForInterrupt() (context.Context, context.CancelFunc) { 37 | c := make(chan os.Signal, 1) 38 | signal.Notify(c, os.Interrupt) 39 | 40 | ctx, cancel := context.WithCancel(context.Background()) 41 | go func() { 42 | oscall := <-c 43 | log.Printf("system call: %+v", oscall) 44 | cancel() 45 | }() 46 | return ctx, cancel 47 | } 48 | 49 | func printAppVersion() { 50 | fmt.Println(version) 51 | fmt.Printf(" build date: %s\r\n commit hash: %s\r\n built by: %s\r\n", date, commit, builtBy) 52 | } 53 | 54 | func run(ctx context.Context, appSettings cfg.AppSettings) error { 55 | log.Print("starting matrix client ...") 56 | var err error 57 | var writeCloser matrix.WriteCloser 58 | if len(appSettings.UserToken) > 0 { 59 | writeCloser, err = matrix.NewMatrixWriteCloserWithToken(appSettings.UserID, appSettings.UserToken, appSettings.HomeserverURL) 60 | } else { 61 | writeCloser, err = matrix.NewMatrixWriteCloser(appSettings.UserID, appSettings.UserPassword, appSettings.HomeserverURL) 62 | } 63 | if err != nil { 64 | return err 65 | } 66 | return server.BuildServer(ctx, writeCloser, appSettings).Start() 67 | } 68 | -------------------------------------------------------------------------------- /cfg/kong.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/alecthomas/kong" 9 | ) 10 | 11 | var cli struct { 12 | VersionMode bool `name:"version" short:"v" help:"Show version info and exit"` 13 | Host string `name:"host" group:"server" env:"GMF_SERVER_HOST" help:"Host address the server connects to" default:"${default_host}"` 14 | Port int `name:"port" group:"server" env:"GMF_SERVER_PORT" help:"Port to run the webserver on" default:"${default_port}"` 15 | AuthScheme string `name:"auth.scheme" group:"server" env:"GMF_AUTH_SCHEME" help:"Set the scheme for required authentication - valid options are: ${auth_scheme_options}"` 16 | AuthCredentials string `name:"auth.credentials" group:"server" env:"GMF_AUTH_CREDENTIALS" help:"Credentials required to forward alerts"` 17 | HomeserverURL string `name:"homeserver" group:"matrix" env:"GMF_MATRIX_HOMESERVER" help:"URL of the homeserver to connect to" default:"${default_homeserver}"` 18 | User string `name:"user" group:"matrix" env:"GMF_MATRIX_USER" help:"Username used to login to matrix"` 19 | Password string `name:"password" group:"matrix" env:"GMF_MATRIX_PASSWORD" help:"Password used to login to matrix"` 20 | Token string `name:"token" group:"matrix" env:"GMF_MATRIX_TOKEN" help:"Auth token used to authenticate with matrix"` 21 | ResolveMode string `name:"resolveMode" group:"alerts" env:"GMF_RESOLVE_MODE" help:"Set how to handle resolved alerts - valid options are: ${resolve_mode_options}" default:"${default_resolve_mode}"` 22 | PersistAlertMap bool `name:"persistAlertMap" group:"alerts" env:"GMF_PERSIST_ALERT_MAP" help:"Persist the internal map between grafana alerts and matrix messages - this is used to support resolving alerts using replies" default:"${default_persist_alert_map}" negatable:"true"` 23 | MetricRounding int `name:"metricRounding" group:"alerts" env:"GMF_METRIC_ROUNDING" help:"Round metric values to the specified decimal places" default:"${default_metric_rounding}"` 24 | LogPayload bool `name:"logPayload" group:"debug" env:"GMF_LOG_PAYLOAD" help:"Print the contents of every alert request received from grafana"` 25 | 26 | Env bool `name:"env" group:"deprecated" help:"No longer has any effect"` 27 | } 28 | 29 | func Parse() AppSettings { 30 | ctx := kong.Parse( 31 | &cli, 32 | kong.Vars{ 33 | "default_host": "0.0.0.0", 34 | "default_port": "6000", 35 | "default_homeserver": "matrix.org", 36 | "default_metric_rounding": "3", 37 | "default_persist_alert_map": "true", 38 | "default_resolve_mode": string(ResolveWithMessage), 39 | "resolve_mode_options": strings.Join(AvailableResolveModesStr(), ", "), 40 | "auth_scheme_options": "bearer", 41 | }, 42 | kong.Name("grafana-matrix-forwarder"), 43 | kong.Description("Forward alerts from Grafana to a Matrix room"), 44 | kong.UsageOnError(), 45 | kong.ExplicitGroups( 46 | []kong.Group{ 47 | { 48 | Key: "server", 49 | Title: "🔌 Server", 50 | Description: "Configure the server used to receive alerts from Grafana", 51 | }, 52 | { 53 | Key: "matrix", 54 | Title: "💬 Matrix", 55 | Description: "How to connect to Matrix to forward alerts", 56 | }, 57 | { 58 | Key: "alerts", 59 | Title: "❗ Alerts", 60 | Description: "Configuration for the alerts themselves", 61 | }, 62 | { 63 | Key: "debug", 64 | Title: "❔ Debug", 65 | Description: "Options to help debugging issues", 66 | }, 67 | { 68 | Key: "deprecated", 69 | Title: "🔻 Deprecated", 70 | Description: "Flags that have been deprecated and should no longer be used", 71 | }, 72 | }, 73 | ), 74 | ) 75 | 76 | flagsValid, messages := validateFlags() 77 | if len(messages) > 0 { 78 | if !flagsValid { 79 | ctx.PrintUsage(false) 80 | } 81 | fmt.Println() 82 | for i := 0; i < len(messages); i++ { 83 | fmt.Println(messages[i]) 84 | } 85 | } 86 | if !flagsValid { 87 | os.Exit(1) 88 | } 89 | 90 | resolveMode, err := ToResolveMode(cli.ResolveMode) 91 | if err != nil { 92 | // This should have already been validated 93 | panic(err) 94 | } 95 | return AppSettings{ 96 | VersionMode: cli.VersionMode, 97 | ServerHost: cli.Host, 98 | ServerPort: cli.Port, 99 | AuthScheme: cli.AuthScheme, 100 | AuthCredentials: cli.AuthCredentials, 101 | HomeserverURL: cli.HomeserverURL, 102 | UserID: cli.User, 103 | UserPassword: cli.Password, 104 | UserToken: cli.Token, 105 | ResolveMode: resolveMode, 106 | LogPayload: cli.LogPayload, 107 | PersistAlertMap: cli.PersistAlertMap, 108 | MetricRounding: cli.MetricRounding, 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /cfg/settings.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // ResolveMode determines how the application will handle resolved alerts 9 | type ResolveMode string 10 | 11 | const ( 12 | ResolveWithReaction ResolveMode = "reaction" 13 | ResolveWithMessage ResolveMode = "message" 14 | ResolveWithReply ResolveMode = "reply" 15 | ) 16 | 17 | // AppSettings includes all application parameters 18 | type AppSettings struct { 19 | VersionMode bool 20 | UserID string 21 | UserPassword string 22 | UserToken string 23 | HomeserverURL string 24 | ServerHost string 25 | MetricRounding int 26 | ServerPort int 27 | LogPayload bool 28 | ResolveMode ResolveMode 29 | PersistAlertMap bool 30 | AuthScheme string 31 | AuthCredentials string 32 | } 33 | 34 | func ToResolveMode(raw string) (ResolveMode, error) { 35 | resolveModeStrLower := strings.ToLower(raw) 36 | if resolveModeStrLower == string(ResolveWithReaction) { 37 | return ResolveWithReaction, nil 38 | } else if resolveModeStrLower == string(ResolveWithMessage) { 39 | return ResolveWithMessage, nil 40 | } else if resolveModeStrLower == string(ResolveWithReply) { 41 | return ResolveWithReply, nil 42 | } 43 | return ResolveWithMessage, fmt.Errorf("invalid resolve mode '%s'", raw) 44 | } 45 | 46 | func AvailableResolveModes() []ResolveMode { 47 | return []ResolveMode{ 48 | ResolveWithMessage, 49 | ResolveWithReaction, 50 | ResolveWithReply, 51 | } 52 | } 53 | 54 | func AvailableResolveModesStr() []string { 55 | modes := AvailableResolveModes() 56 | modesStr := make([]string, len(modes)) 57 | for i, m := range modes { 58 | modesStr[i] = string(m) 59 | } 60 | return modesStr 61 | } 62 | -------------------------------------------------------------------------------- /cfg/validate.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import "strings" 4 | 5 | const ( 6 | minServerPort = 1000 7 | maxServerPort = 65535 8 | ) 9 | 10 | func validateFlags() (bool, []string) { 11 | var flagsValid = true 12 | var messages = []string{} 13 | if !cli.VersionMode { 14 | if cli.Env { 15 | messages = append(messages, "warn: the env flag has been deprecated and no longer has any function") 16 | } 17 | if cli.User == "" { 18 | messages = append(messages, "error: matrix username must not be blank") 19 | flagsValid = false 20 | } 21 | passwordSet := cli.Password != "" 22 | tokenSet := cli.Token != "" 23 | if passwordSet == tokenSet { 24 | messages = append(messages, "error: must set either password or token (only one, not both)") 25 | flagsValid = false 26 | } 27 | if cli.HomeserverURL == "" { 28 | messages = append(messages, "error: matrix homeserver url must not be blank") 29 | flagsValid = false 30 | } 31 | if cli.Port < minServerPort || cli.Port > maxServerPort { 32 | messages = append(messages, "error: invalid server port selected") 33 | flagsValid = false 34 | } 35 | if (cli.AuthScheme == "") != (cli.AuthCredentials == "") { 36 | messages = append(messages, "error: invalid auth setup - both scheme and credentials should be set") 37 | flagsValid = false 38 | } 39 | if strings.ToLower(cli.AuthScheme) != "" && strings.ToLower(cli.AuthScheme) != "bearer" { 40 | messages = append(messages, "error: unsupported auth scheme selected") 41 | flagsValid = false 42 | } 43 | _, err := ToResolveMode(cli.ResolveMode) 44 | if err != nil { 45 | messages = append(messages, "error: invalid resolve mode selected") 46 | flagsValid = false 47 | } 48 | } 49 | return flagsValid, messages 50 | } 51 | -------------------------------------------------------------------------------- /formatter/message.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "grafana-matrix-forwarder/matrix" 5 | "grafana-matrix-forwarder/model" 6 | "log" 7 | ) 8 | 9 | type alertMessageData struct { 10 | MetricRounding int 11 | StateStr string 12 | StateEmoji string 13 | Payload model.AlertData 14 | } 15 | 16 | func GenerateMessage(alert model.AlertData, metricRounding int) (matrix.FormattedMessage, error) { 17 | var messageData = alertMessageData{ 18 | StateStr: "UNKNOWN", 19 | StateEmoji: "❓", 20 | MetricRounding: metricRounding, 21 | Payload: alert, 22 | } 23 | switch alert.State { 24 | case model.AlertStateAlerting: 25 | messageData.StateStr = "ALERT" 26 | messageData.StateEmoji = "💔" 27 | case model.AlertStateResolved: 28 | messageData.StateStr = "RESOLVED" 29 | messageData.StateEmoji = "💚" 30 | case model.AlertStateNoData: 31 | messageData.StateStr = "NO DATA" 32 | messageData.StateEmoji = "❓" 33 | default: 34 | log.Printf("alert received with unknown state: %s", alert.State) 35 | } 36 | html, err := executeHtmlTemplate(alertMessageTemplate, messageData) 37 | if err != nil { 38 | return matrix.FormattedMessage{}, err 39 | } 40 | text := htmlMessageToTextMessage(html) 41 | return matrix.FormattedMessage{ 42 | TextBody: text, 43 | HtmlBody: html, 44 | }, err 45 | } 46 | 47 | func GenerateReply(originalHtmlMessage string, alert model.AlertData) (matrix.FormattedMessage, error) { 48 | if alert.State == model.AlertStateResolved { 49 | html, err := executeTextTemplate(resolveReplyTemplate, originalHtmlMessage) 50 | if err != nil { 51 | return matrix.FormattedMessage{}, err 52 | } 53 | text := resolveReplyPlainStr 54 | return matrix.FormattedMessage{ 55 | TextBody: text, 56 | HtmlBody: html, 57 | }, err 58 | } 59 | return matrix.FormattedMessage{}, nil 60 | } 61 | -------------------------------------------------------------------------------- /formatter/message_test.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "grafana-matrix-forwarder/model" 5 | "testing" 6 | ) 7 | 8 | func TestGenerateMessage(t *testing.T) { 9 | type args struct { 10 | alert model.AlertData 11 | metricRounding int 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | wantMessageText string 17 | wantMessageHtml string 18 | wantErr bool 19 | }{ 20 | { 21 | name: "alertingStateTest", 22 | args: args{ 23 | alert: model.AlertData{ 24 | State: "alerting", 25 | RuleURL: "http://example.com", 26 | RuleName: "sample", 27 | Message: "sample message", 28 | }, metricRounding: 0}, 29 | wantMessageHtml: "💔 ALERT

Rule: sample | sample message

", 30 | wantMessageText: "💔 ALERT Rule: sample | sample message", 31 | wantErr: false, 32 | }, 33 | { 34 | name: "alertingStateWithEvalMatchesTest", 35 | args: args{ 36 | alert: model.AlertData{ 37 | State: "alerting", 38 | RuleURL: "http://example.com", 39 | RuleName: "sample", 40 | Message: "sample message", 41 | EvalMatches: []struct { 42 | Value float64 43 | Metric string 44 | Tags map[string]string 45 | }{ 46 | { 47 | Value: 10.65124, 48 | Metric: "sample", 49 | Tags: map[string]string{}, 50 | }, 51 | }, 52 | }, 53 | metricRounding: 5, 54 | }, 55 | wantMessageHtml: "💔 ALERT

Rule: sample | sample message

  • sample: 10.65124
", 56 | wantMessageText: "💔 ALERT Rule: sample | sample message sample: 10.65124", 57 | wantErr: false, 58 | }, 59 | { 60 | name: "alertingStateWithEvalMatchesAndTagsTest", 61 | args: args{ 62 | alert: model.AlertData{ 63 | State: "alerting", 64 | RuleURL: "http://example.com", 65 | RuleName: "sample", 66 | Message: "sample message", 67 | EvalMatches: []struct { 68 | Value float64 69 | Metric string 70 | Tags map[string]string 71 | }{ 72 | { 73 | Value: 10.65124, 74 | Metric: "sample", 75 | }, 76 | }, 77 | Tags: map[string]string{"key1": "value1", "key2": "value2"}, 78 | }, 79 | metricRounding: 5, 80 | }, 81 | wantMessageHtml: "💔 ALERT

Rule: sample | sample message

  • sample: 10.65124

Tags:

  • key1: value1
  • key2: value2
", 82 | wantMessageText: "💔 ALERT Rule: sample | sample message sample: 10.65124 Tags: key1: value1key2: value2", 83 | wantErr: false, 84 | }, 85 | { 86 | name: "okStateTest", 87 | args: args{ 88 | alert: model.AlertData{ 89 | State: "ok", 90 | RuleURL: "http://example.com", 91 | RuleName: "sample", 92 | Message: "sample message", 93 | }, 94 | }, 95 | wantMessageHtml: "💚 RESOLVED

Rule: sample | sample message

", 96 | wantMessageText: "💚 RESOLVED Rule: sample | sample message", 97 | wantErr: false, 98 | }, 99 | { 100 | name: "noDataStateTest", 101 | args: args{ 102 | alert: model.AlertData{ 103 | State: "no_data", 104 | RuleURL: "http://example.com", 105 | RuleName: "sample", 106 | Message: "sample message", 107 | }, 108 | }, 109 | wantMessageHtml: "❓ NO DATA

Rule: sample | sample message

", 110 | wantMessageText: "❓ NO DATA Rule: sample | sample message", 111 | wantErr: false, 112 | }, 113 | { 114 | name: "unknownStateTest", 115 | args: args{ 116 | alert: model.AlertData{ 117 | State: "invalid state", 118 | RuleURL: "http://example.com", 119 | RuleName: "sample", 120 | Message: "sample message", 121 | }, 122 | }, 123 | wantMessageHtml: "❓ UNKNOWN

Rule: sample | sample message

", 124 | wantMessageText: "❓ UNKNOWN Rule: sample | sample message", 125 | wantErr: false, 126 | }, 127 | } 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | gotMessage, err := GenerateMessage(tt.args.alert, tt.args.metricRounding) 131 | if (err != nil) != tt.wantErr { 132 | t.Errorf("GenerateMessage() error = %v, wantErr %v", err, tt.wantErr) 133 | return 134 | } 135 | if gotMessage.TextBody != tt.wantMessageText { 136 | t.Errorf("GenerateMessage() text = %v, want %v", gotMessage.TextBody, tt.wantMessageText) 137 | } 138 | if gotMessage.HtmlBody != tt.wantMessageHtml { 139 | t.Errorf("GenerateMessage() html = %v, want %v", gotMessage.HtmlBody, tt.wantMessageHtml) 140 | } 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /formatter/reaction.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import "grafana-matrix-forwarder/model" 4 | 5 | func GenerateReaction(alert model.AlertData) string { 6 | if alert.State == model.AlertStateResolved { 7 | return resolvedReactionStr 8 | } 9 | return "" 10 | } 11 | -------------------------------------------------------------------------------- /formatter/templates.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "fmt" 5 | htmlTemplate "html/template" 6 | textTemplate "text/template" 7 | ) 8 | 9 | const ( 10 | alertMessageTemplateStr = `{{ .StateEmoji }} {{ .StateStr }} 11 | {{- with .Payload }}

Rule: {{ .RuleName }}{{ if .Message }} | {{ .Message }}{{ end }}

12 | {{- if gt (len .EvalMatches) 0 }}
    {{ range $match := .EvalMatches }}
  • {{ .Metric }}: {{ RoundValue .Value $.MetricRounding }}
  • {{ end }}
{{ end }} 13 | {{- if gt (len .Tags) 0 }}

Tags:

    {{ range $tagKey, $tagValue := .Tags }}
  • {{ $tagKey }}: {{ $tagValue }}
  • {{ end }}
{{ end }} 14 | {{- with .RawData }}
{{ . }}
{{ end }}{{ end }}` 15 | resolvedReactionStr = `✅` 16 | resolveReplyStr = "
{{ . }}
💚 ️RESOLVED" 17 | resolveReplyPlainStr = `💚 ️RESOLVED` 18 | ) 19 | 20 | var ( 21 | alertMessageTemplate = htmlTemplate.Must(htmlTemplate.New("alertMessage").Funcs(htmlTemplate.FuncMap{ 22 | "RoundValue": roundMetricValue, 23 | }).Parse(alertMessageTemplateStr)) 24 | resolveReplyTemplate = textTemplate.Must(textTemplate.New("resolveReply").Parse(resolveReplyStr)) 25 | ) 26 | 27 | func roundMetricValue(rawValue float64, metricRounding int) string { 28 | var format string 29 | if metricRounding >= 0 { 30 | format = fmt.Sprintf("%%.%df", metricRounding) 31 | } else { 32 | format = "%v" 33 | } 34 | return fmt.Sprintf(format, rawValue) 35 | } 36 | -------------------------------------------------------------------------------- /formatter/util.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "bytes" 5 | htmlTemplate "html/template" 6 | "regexp" 7 | "strings" 8 | textTemplate "text/template" 9 | ) 10 | 11 | var ( 12 | htmlTagRegex = regexp.MustCompile(`<.*?>`) 13 | htmlParagraphRegex = regexp.MustCompile(``) 14 | ) 15 | 16 | func htmlMessageToTextMessage(input string) string { 17 | return strings.TrimSpace(stripHtmlTagsFromString(input)) 18 | } 19 | 20 | func stripHtmlTagsFromString(input string) string { 21 | bodyWithoutParagraphs := htmlParagraphRegex.ReplaceAllString(input, " ") 22 | plainBody := htmlTagRegex.ReplaceAllString(bodyWithoutParagraphs, "") 23 | return plainBody 24 | } 25 | 26 | func executeHtmlTemplate(template *htmlTemplate.Template, data interface{}) (string, error) { 27 | buffer := new(bytes.Buffer) 28 | err := template.Execute(buffer, data) 29 | return buffer.String(), err 30 | } 31 | 32 | func executeTextTemplate(template *textTemplate.Template, data interface{}) (string, error) { 33 | buffer := new(bytes.Buffer) 34 | err := template.Execute(buffer, data) 35 | return buffer.String(), err 36 | } 37 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module grafana-matrix-forwarder 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.8.0 7 | github.com/prometheus/client_golang v1.16.0 8 | maunium.net/go/mautrix v0.16.0 9 | ) 10 | 11 | require ( 12 | github.com/beorn7/perks v1.0.1 // indirect 13 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 14 | github.com/golang/protobuf v1.5.3 // indirect 15 | github.com/mattn/go-colorable v0.1.13 // indirect 16 | github.com/mattn/go-isatty v0.0.19 // indirect 17 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 18 | github.com/prometheus/client_model v0.4.0 // indirect 19 | github.com/prometheus/common v0.44.0 // indirect 20 | github.com/prometheus/procfs v0.11.1 // indirect 21 | github.com/rs/zerolog v1.30.0 // indirect 22 | github.com/tidwall/gjson v1.16.0 // indirect 23 | github.com/tidwall/match v1.1.1 // indirect 24 | github.com/tidwall/pretty v1.2.1 // indirect 25 | github.com/tidwall/sjson v1.2.5 // indirect 26 | go.mau.fi/util v0.0.0-20230906155759-14bad39a8718 // indirect 27 | golang.org/x/crypto v0.13.0 // indirect 28 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 29 | golang.org/x/net v0.15.0 // indirect 30 | golang.org/x/sys v0.12.0 // indirect 31 | google.golang.org/protobuf v1.31.0 // indirect 32 | maunium.net/go/maulogger/v2 v2.4.1 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= 2 | github.com/alecthomas/kong v0.8.0 h1:ryDCzutfIqJPnNn0omnrgHLbAggDQM2VWHikE1xqK7s= 3 | github.com/alecthomas/kong v0.8.0/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= 4 | github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 8 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 12 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 13 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 14 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 15 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 16 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 17 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 18 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 19 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 20 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 21 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 22 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 23 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 24 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 25 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 26 | github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= 27 | github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 28 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= 31 | github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= 32 | github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= 33 | github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= 34 | github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= 35 | github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= 36 | github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= 37 | github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= 38 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 39 | github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= 40 | github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= 41 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 42 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 43 | github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg= 44 | github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 45 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 46 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 47 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 48 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 49 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 50 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 51 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 52 | go.mau.fi/util v0.0.0-20230906155759-14bad39a8718 h1:hmm5bZqE0M8+Uvys0HJPCSbAIZIwYtTkBKYPjAWHuMM= 53 | go.mau.fi/util v0.0.0-20230906155759-14bad39a8718/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84= 54 | golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= 55 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 56 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 57 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 58 | golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= 59 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 60 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 61 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 66 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 68 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 69 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 70 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 71 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 73 | maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8= 74 | maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho= 75 | maunium.net/go/mautrix v0.16.0 h1:iUqCzJE2yqBC1ddAK6eAn159My8rLb4X8g4SFtQh2Dk= 76 | maunium.net/go/mautrix v0.16.0/go.mod h1:XAjE9pTSGcr6vXaiNgQGiip7tddJ8FQV1a29u2QdBG4= 77 | -------------------------------------------------------------------------------- /matrix/interfaces.go: -------------------------------------------------------------------------------- 1 | package matrix 2 | 3 | // WriteCloser allows writing JSON data to a matrix room and closing the connection 4 | type WriteCloser interface { 5 | // Close the matrix connection 6 | Close() error 7 | // GetWriter instance to allow writing data to a matrix room 8 | GetWriter() Writer 9 | } 10 | 11 | // Writer handles writing data to a matrix room 12 | type Writer interface { 13 | // Send a message payload to a given room and get back the event ID if successful 14 | Send(roomID string, body FormattedMessage) (string, error) 15 | // Reply to the provided event ID with the provided message, returns the event ID if successful 16 | Reply(roomID string, eventID string, body FormattedMessage) (string, error) 17 | // React to a given event ID, returns the new event ID if successful 18 | React(roomID string, eventID string, reaction string) (string, error) 19 | } 20 | 21 | // FormattedMessage contains the plain and formatted versions of a message that get rendered in matrix clients. 22 | // They should both represent the same thing. 23 | type FormattedMessage struct { 24 | TextBody string 25 | HtmlBody string 26 | } 27 | -------------------------------------------------------------------------------- /matrix/matrix.go: -------------------------------------------------------------------------------- 1 | package matrix 2 | 3 | import ( 4 | "log" 5 | 6 | "maunium.net/go/mautrix" 7 | "maunium.net/go/mautrix/event" 8 | "maunium.net/go/mautrix/id" 9 | ) 10 | 11 | // NewMatrixWriteCloser logs in to the provided matrix server URL using the provided user ID and password 12 | // and returns a matrix WriteCloser 13 | func NewMatrixWriteCloser(userID, userPassword, homeserverURL string) (WriteCloser, error) { 14 | client, err := mautrix.NewClient(homeserverURL, id.UserID(userID), "") 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | log.Print("logging into matrix with username + password") 20 | _, err = client.Login(&mautrix.ReqLogin{ 21 | Type: "m.login.password", 22 | Identifier: mautrix.UserIdentifier{ 23 | Type: "m.id.user", 24 | User: userID, 25 | }, 26 | Password: userPassword, 27 | InitialDeviceDisplayName: "", 28 | StoreCredentials: true, 29 | }) 30 | return buildMatrixWriteCloser(client, true), err 31 | } 32 | 33 | // NewMatrixWriteCloser creates a new WriteCloser with the provided user ID and token 34 | func NewMatrixWriteCloserWithToken(userID, token, homeserverURL string) (WriteCloser, error) { 35 | log.Print("using matrix auth token") 36 | client, err := mautrix.NewClient(homeserverURL, id.UserID(userID), token) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return buildMatrixWriteCloser(client, false), err 41 | } 42 | 43 | // buildMatrixWriteCloser builds a WriteCloser from a raw matrix client 44 | func buildMatrixWriteCloser(matrixClient *mautrix.Client, closeable bool) WriteCloser { 45 | return writeCloser{ 46 | writer: writer{ 47 | matrixClient: matrixClient, 48 | }, 49 | closeable: closeable, 50 | } 51 | } 52 | 53 | type writeCloser struct { 54 | writer writer 55 | closeable bool 56 | } 57 | 58 | type writer struct { 59 | matrixClient *mautrix.Client 60 | } 61 | 62 | func (wc writeCloser) GetWriter() Writer { 63 | return wc.writer 64 | } 65 | 66 | func (wc writeCloser) Close() error { 67 | if !wc.closeable { 68 | return nil 69 | } 70 | _, err := wc.writer.matrixClient.Logout() 71 | return err 72 | } 73 | 74 | func buildFormattedMessagePayload(body FormattedMessage) *event.MessageEventContent { 75 | return &event.MessageEventContent{ 76 | MsgType: "m.text", 77 | Body: body.TextBody, 78 | Format: "org.matrix.custom.html", 79 | FormattedBody: body.HtmlBody, 80 | } 81 | } 82 | 83 | func (w writer) Send(roomID string, body FormattedMessage) (string, error) { 84 | payload := buildFormattedMessagePayload(body) 85 | resp, err := w.sendPayload(roomID, event.EventMessage, payload) 86 | if err != nil { 87 | return "", err 88 | } 89 | return resp.EventID.String(), err 90 | } 91 | 92 | func (w writer) Reply(roomID string, eventID string, body FormattedMessage) (string, error) { 93 | payload := buildFormattedMessagePayload(body) 94 | payload.RelatesTo = &event.RelatesTo{ 95 | EventID: id.EventID(eventID), 96 | Type: event.RelReference, 97 | } 98 | resp, err := w.sendPayload(roomID, event.EventMessage, &payload) 99 | if err != nil { 100 | return "", err 101 | } 102 | return resp.EventID.String(), err 103 | } 104 | 105 | func (w writer) React(roomID string, eventID string, reaction string) (string, error) { 106 | // Temporary fix to support sending reactions. The key is to pass a pointer to the send method. 107 | // PR that addresses issue and fix: https://github.com/tulir/mautrix-go/pull/21 108 | // Fixed by: https://github.com/tulir/mautrix-go/commit/617e6c94cc3a2f046434bf262fadd993daf02141 109 | payload := event.ReactionEventContent{ 110 | RelatesTo: event.RelatesTo{ 111 | EventID: id.EventID(eventID), 112 | Type: event.RelAnnotation, 113 | Key: reaction, 114 | }, 115 | } 116 | resp, err := w.sendPayload(roomID, event.EventReaction, &payload) 117 | if err != nil { 118 | return "", err 119 | } 120 | return resp.EventID.String(), err 121 | } 122 | 123 | func (w writer) sendPayload(roomID string, eventType event.Type, messagePayload interface{}) (*mautrix.RespSendEvent, error) { 124 | return w.matrixClient.SendMessageEvent(id.RoomID(roomID), eventType, messagePayload) 125 | } 126 | -------------------------------------------------------------------------------- /model/data.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | const ( 4 | // AlertStateAlerting represents the state name grafana uses for alerts that are firing 5 | AlertStateAlerting = "alerting" 6 | // AlertStateResolved represents the state name grafana uses for alerts that have been resolved 7 | AlertStateResolved = "ok" 8 | // AlertStateNoData represents the state name grafana uses for alerts that are firing because of missing data 9 | AlertStateNoData = "no_data" 10 | ) 11 | 12 | type AlertData struct { 13 | Id string 14 | State string 15 | RuleURL string 16 | RuleName string 17 | Message string 18 | RawData string 19 | Tags map[string]string 20 | EvalMatches []struct { 21 | Value float64 22 | Metric string 23 | Tags map[string]string 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "grafana-matrix-forwarder/model" 6 | "log" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type RequestHandler interface { 12 | ParseRequest(request *http.Request, logPayload bool) (roomIDs []string, alerts []model.AlertData, err error) 13 | } 14 | 15 | func (server *Server) HandleGrafanaAlert(handler RequestHandler, response http.ResponseWriter, request *http.Request) { 16 | if !server.isAuthorised(request) { 17 | log.Print("unauthorised request (credentials do not match)") 18 | response.WriteHeader(http.StatusUnauthorized) 19 | return 20 | } 21 | err := server.handleGrafanaAlertInner(handler, response, request) 22 | if err != nil { 23 | server.metricsCollector.IncrementFailure() 24 | log.Print(err) 25 | response.WriteHeader(http.StatusInternalServerError) 26 | } else { 27 | server.metricsCollector.IncrementSuccess() 28 | } 29 | } 30 | 31 | func (server *Server) handleGrafanaAlertInner(handler RequestHandler, response http.ResponseWriter, request *http.Request) error { 32 | roomIDs, alerts, err := handler.ParseRequest(request, server.appSettings.LogPayload) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | server.metricsCollector.RecordAlerts(alerts) 38 | 39 | err = server.alertForwarder.ForwardEvents(roomIDs, alerts) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | response.WriteHeader(http.StatusOK) 45 | _, err = response.Write([]byte("OK")) 46 | return err 47 | } 48 | 49 | func (server *Server) isAuthorised(request *http.Request) bool { 50 | if strings.ToLower(server.appSettings.AuthScheme) == "bearer" { 51 | authHeader := request.Header.Get("Authorization") 52 | requiredToken := fmt.Sprintf("Bearer %s", server.appSettings.AuthCredentials) 53 | return authHeader == requiredToken 54 | } 55 | return true 56 | } 57 | -------------------------------------------------------------------------------- /server/metrics/collector.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "grafana-matrix-forwarder/model" 6 | ) 7 | 8 | type Collector struct { 9 | successForwardCount int 10 | failForwardCount int 11 | alertCountByState map[string]int 12 | } 13 | 14 | func NewCollector() *Collector { 15 | return &Collector{ 16 | successForwardCount: 0, 17 | failForwardCount: 0, 18 | alertCountByState: map[string]int{}, 19 | } 20 | } 21 | 22 | func (c *Collector) Describe(ch chan<- *prometheus.Desc) { 23 | ch <- metricForwardCount 24 | ch <- metricAlertCount 25 | ch <- upMetric 26 | } 27 | 28 | func (c *Collector) Collect(ch chan<- prometheus.Metric) { 29 | ch <- prometheus.MustNewConstMetric(upMetric, prometheus.GaugeValue, float64(1)) 30 | c.collectForwardCount(ch) 31 | c.collectAlertCount(ch) 32 | } 33 | 34 | func (c *Collector) collectForwardCount(ch chan<- prometheus.Metric) { 35 | ch <- prometheus.MustNewConstMetric( 36 | metricForwardCount, prometheus.CounterValue, float64(c.successForwardCount), "success", 37 | ) 38 | ch <- prometheus.MustNewConstMetric( 39 | metricForwardCount, prometheus.CounterValue, float64(c.failForwardCount), "fail", 40 | ) 41 | } 42 | 43 | func (c *Collector) collectAlertCount(ch chan<- prometheus.Metric) { 44 | for state, count := range c.alertCountByState { 45 | ch <- prometheus.MustNewConstMetric( 46 | metricAlertCount, prometheus.CounterValue, float64(count), state, 47 | ) 48 | } 49 | } 50 | 51 | func (c *Collector) IncrementSuccess() { 52 | c.successForwardCount++ 53 | } 54 | 55 | func (c *Collector) IncrementFailure() { 56 | c.failForwardCount++ 57 | } 58 | 59 | func (c *Collector) RecordAlerts(alerts []model.AlertData) { 60 | for _, alert := range alerts { 61 | if count, ok := c.alertCountByState[alert.State]; !ok { 62 | c.alertCountByState[alert.State] = 1 63 | } else { 64 | c.alertCountByState[alert.State] = count + 1 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /server/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | ) 6 | 7 | const namespace = "gmf" 8 | 9 | var ( 10 | upMetric = prometheus.NewDesc( 11 | prometheus.BuildFQName(namespace, "", "up"), 12 | "Alert forwarder is up and running", 13 | nil, nil, 14 | ) 15 | metricForwardCount = prometheus.NewDesc( 16 | prometheus.BuildFQName(namespace, "", "forwards"), 17 | "Successful and failed alert forwards", 18 | []string{"result"}, nil, 19 | ) 20 | metricAlertCount = prometheus.NewDesc( 21 | prometheus.BuildFQName(namespace, "", "alerts"), 22 | "Alert states being processed by the forwarder", 23 | []string{"state"}, nil, 24 | ) 25 | ) 26 | -------------------------------------------------------------------------------- /server/metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "github.com/prometheus/client_golang/prometheus/promhttp" 6 | "io/ioutil" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func Test_serverMetrics_buildMetrics1(t *testing.T) { 12 | type fields struct { 13 | totalForwardCount int 14 | successForwardCount int 15 | failForwardCount int 16 | alertingAlertCount int 17 | resolvedAlertCount int 18 | noDataAlertCount int 19 | otherAlertCount int 20 | } 21 | tests := []struct { 22 | name string 23 | fields fields 24 | wantMetrics string 25 | wantErr bool 26 | }{{ 27 | name: "happyPathTest", 28 | fields: fields{ 29 | successForwardCount: 4, 30 | failForwardCount: 6, 31 | alertingAlertCount: 5, 32 | resolvedAlertCount: 3, 33 | noDataAlertCount: 1, 34 | otherAlertCount: 1, 35 | }, 36 | wantMetrics: `# HELP gmf_alerts Alert states being processed by the forwarder 37 | # TYPE gmf_alerts counter 38 | gmf_alerts{state="alerting"} 5 39 | gmf_alerts{state="no_data"} 1 40 | gmf_alerts{state="ok"} 3 41 | gmf_alerts{state="other"} 1 42 | # HELP gmf_forwards Successful and failed alert forwards 43 | # TYPE gmf_forwards counter 44 | gmf_forwards{result="fail"} 6 45 | gmf_forwards{result="success"} 4 46 | # HELP gmf_up Alert forwarder is up and running 47 | # TYPE gmf_up gauge 48 | gmf_up 1 49 | `, 50 | wantErr: false, 51 | }, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | serverMetrics := &Collector{ 56 | successForwardCount: tt.fields.successForwardCount, 57 | failForwardCount: tt.fields.failForwardCount, 58 | alertCountByState: map[string]int{ 59 | "alerting": tt.fields.alertingAlertCount, 60 | "ok": tt.fields.resolvedAlertCount, 61 | "no_data": tt.fields.noDataAlertCount, 62 | "other": tt.fields.otherAlertCount, 63 | }, 64 | } 65 | registry := prometheus.NewRegistry() 66 | registry.MustRegister(serverMetrics) 67 | 68 | s := httptest.NewServer(promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) 69 | defer s.Close() 70 | 71 | resp, err := s.Client().Get(s.URL) 72 | gotMetricsBytes, err := ioutil.ReadAll(resp.Body) 73 | gotMetrics := string(gotMetricsBytes) 74 | 75 | if (err != nil) != tt.wantErr { 76 | t.Errorf("buildMetrics() error = %v, wantErr %v", err, tt.wantErr) 77 | return 78 | } 79 | if gotMetrics != tt.wantMetrics { 80 | t.Errorf("buildMetrics() gotMetrics = %v, want %v", gotMetrics, tt.wantMetrics) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "grafana-matrix-forwarder/cfg" 7 | "grafana-matrix-forwarder/matrix" 8 | "grafana-matrix-forwarder/server/metrics" 9 | v0 "grafana-matrix-forwarder/server/v0" 10 | v1 "grafana-matrix-forwarder/server/v1" 11 | "grafana-matrix-forwarder/service" 12 | 13 | "github.com/prometheus/client_golang/prometheus" 14 | "github.com/prometheus/client_golang/prometheus/promhttp" 15 | 16 | "log" 17 | "net/http" 18 | "time" 19 | ) 20 | 21 | // Server data structure that holds data necessary for the web server to function 22 | type Server struct { 23 | ctx context.Context 24 | matrixWriteCloser matrix.WriteCloser 25 | appSettings cfg.AppSettings 26 | alertForwarder service.Forwarder 27 | metricsCollector *metrics.Collector 28 | } 29 | 30 | // BuildServer builds a Server instance based on the provided context.Context, a matrix.WriteCloser, and the cfg.AppSettings 31 | func BuildServer(ctx context.Context, matrixWriteCloser matrix.WriteCloser, appSettings cfg.AppSettings) Server { 32 | return Server{ 33 | ctx: ctx, 34 | matrixWriteCloser: matrixWriteCloser, 35 | appSettings: appSettings, 36 | alertForwarder: service.NewForwarder(appSettings, matrixWriteCloser.GetWriter()), 37 | metricsCollector: metrics.NewCollector(), 38 | } 39 | } 40 | 41 | // Start the web server and listen for incoming requests 42 | func (server Server) Start() (err error) { 43 | log.Print("starting webserver ...") 44 | log.Printf("resolve mode set to: %s", server.appSettings.ResolveMode) 45 | mux := http.NewServeMux() 46 | mux.Handle("/api/v0/forward", http.HandlerFunc( 47 | func(response http.ResponseWriter, request *http.Request) { 48 | server.HandleGrafanaAlert(&v0.Handler{}, response, request) 49 | }, 50 | )) 51 | mux.Handle("/api/v1/standard", http.HandlerFunc( 52 | func(response http.ResponseWriter, request *http.Request) { 53 | server.HandleGrafanaAlert(&v0.Handler{}, response, request) 54 | }, 55 | )) 56 | mux.Handle("/api/v1/unified", http.HandlerFunc( 57 | func(response http.ResponseWriter, request *http.Request) { 58 | server.HandleGrafanaAlert(&v1.Handler{}, response, request) 59 | }, 60 | )) 61 | mux.Handle("/metrics", promhttp.Handler()) 62 | 63 | prometheus.MustRegister(server.metricsCollector) 64 | serverAddr := fmt.Sprintf("%s:%d", server.appSettings.ServerHost, server.appSettings.ServerPort) 65 | srv := &http.Server{ 66 | Addr: serverAddr, 67 | Handler: mux, 68 | ReadHeaderTimeout: 10 * time.Second, 69 | ReadTimeout: 10 * time.Second, 70 | WriteTimeout: 10 * time.Second, 71 | IdleTimeout: 30 * time.Second, 72 | } 73 | 74 | go func() { 75 | if err = srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 76 | log.Fatalf("server error: %+s\n", err) 77 | } 78 | }() 79 | 80 | log.Printf("webserver listening at %s", serverAddr) 81 | log.Print("ready") 82 | 83 | <-server.ctx.Done() 84 | 85 | log.Printf("shutting down ...") 86 | 87 | ctxShutDown, cancel := context.WithTimeout(context.Background(), 5*time.Second) 88 | defer func() { 89 | cancel() 90 | }() 91 | 92 | if err = server.matrixWriteCloser.Close(); err != nil { 93 | log.Fatalf("matrix client logout failed: %+s", err) 94 | } 95 | if err = srv.Shutdown(ctxShutDown); err != nil { 96 | log.Fatalf("server shutdown failed: %+s", err) 97 | } 98 | 99 | if err == http.ErrServerClosed { 100 | err = nil 101 | } 102 | return 103 | } 104 | -------------------------------------------------------------------------------- /server/util/http.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | func GetRoomIDsFromURL(url *url.URL) ([]string, error) { 12 | roomIDs, ok := url.Query()["roomId"] 13 | if !ok || len(roomIDs) < 1 { 14 | return nil, fmt.Errorf("url param 'roomId' is missing") 15 | } 16 | return roomIDs, nil 17 | } 18 | 19 | func GetRequestBodyAsBytes(request *http.Request) ([]byte, error) { 20 | return ioutil.ReadAll(request.Body) 21 | } 22 | 23 | func LogRequestPayload(request *http.Request, bodyBytes []byte) { 24 | log.Printf("%s request received at URL: %s", request.Method, request.URL.String()) 25 | body := string(bodyBytes) 26 | fmt.Println(body) 27 | } 28 | -------------------------------------------------------------------------------- /server/v0/handler.go: -------------------------------------------------------------------------------- 1 | package v0 2 | 3 | import ( 4 | "encoding/json" 5 | "grafana-matrix-forwarder/model" 6 | "grafana-matrix-forwarder/server/util" 7 | "net/http" 8 | ) 9 | 10 | type Handler struct { 11 | } 12 | 13 | func (h Handler) ParseRequest(request *http.Request, logPayload bool) (roomIDs []string, alerts []model.AlertData, err error) { 14 | bodyBytes, err := util.GetRequestBodyAsBytes(request) 15 | if err != nil { 16 | return 17 | } 18 | if logPayload { 19 | util.LogRequestPayload(request, bodyBytes) 20 | } 21 | 22 | roomIDs, err = util.GetRoomIDsFromURL(request.URL) 23 | if err != nil { 24 | return 25 | } 26 | 27 | alertPayload, err := getAlertPayloadFromRequestBody(bodyBytes) 28 | if err != nil { 29 | return 30 | } 31 | 32 | alerts = make([]model.AlertData, 1) 33 | alerts[0] = alertPayload.ToForwarderData() 34 | return 35 | } 36 | 37 | func getAlertPayloadFromRequestBody(bodyBytes []byte) (alertPayload alertPayload, err error) { 38 | err = json.Unmarshal(bodyBytes, &alertPayload) 39 | return 40 | } 41 | -------------------------------------------------------------------------------- /server/v0/handler_test.go: -------------------------------------------------------------------------------- 1 | package v0 2 | 3 | import ( 4 | "grafana-matrix-forwarder/server/util" 5 | "net/url" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func Test_getRoomIDsFromURL(t *testing.T) { 11 | type args struct { 12 | url string 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | want []string 18 | wantErr bool 19 | }{ 20 | { 21 | name: "GIVEN url with no room id WHEN get room ids THEN error returned", 22 | args: args{url: "http://localhost/"}, 23 | want: nil, 24 | wantErr: true, 25 | }, 26 | { 27 | name: "GIVEN url with a single room id WHEN get room ids THEN array with one room id returned", 28 | args: args{url: "http://localhost/?roomId=test"}, 29 | want: []string{"test"}, 30 | wantErr: false, 31 | }, 32 | { 33 | name: "GIVEN url with a multiple room ids WHEN get room ids THEN array of all room ids returned", 34 | args: args{url: "http://localhost/?roomId=test1&roomId=test2&somethingElse=test3"}, 35 | want: []string{"test1", "test2"}, 36 | wantErr: false, 37 | }, 38 | } 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | inputUrl, err := url.Parse(tt.args.url) 42 | if err != nil { 43 | t.Fatalf("Invalid test data - not a valid url") 44 | } 45 | got, err := util.GetRoomIDsFromURL(inputUrl) 46 | if (err != nil) != tt.wantErr { 47 | t.Errorf("getRoomIDsFromURL() error = %v, wantErr %v", err, tt.wantErr) 48 | return 49 | } 50 | if !reflect.DeepEqual(got, tt.want) { 51 | t.Errorf("getRoomIDsFromURL() got = %v, want %v", got, tt.want) 52 | } 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /server/v0/payload.go: -------------------------------------------------------------------------------- 1 | package v0 2 | 3 | import ( 4 | "fmt" 5 | "grafana-matrix-forwarder/model" 6 | ) 7 | 8 | // alertPayload stores the request data sent with the grafana alert webhook 9 | type alertPayload struct { 10 | Title string `json:"title"` 11 | Message string `json:"message"` 12 | State string `json:"state"` 13 | RuleName string `json:"ruleName"` 14 | RuleURL string `json:"ruleUrl"` 15 | RuleID int `json:"ruleId"` 16 | OrgID int `json:"orgId"` 17 | DashboardID int `json:"dashboardId"` 18 | PanelID int `json:"panelId"` 19 | EvalMatches []struct { 20 | Value float64 `json:"value"` 21 | Metric string `json:"metric"` 22 | Tags map[string]string `json:"tags"` 23 | } `json:"evalMatches"` 24 | Tags map[string]string `json:"tags"` 25 | } 26 | 27 | // FullRuleID is defined as the combination of the OrgID, DashboardID, PanelID, and RuleID 28 | func (payload alertPayload) FullRuleID() string { 29 | return fmt.Sprintf("%d.%d.%d.%d", payload.OrgID, payload.DashboardID, payload.PanelID, payload.RuleID) 30 | } 31 | 32 | func (payload alertPayload) ToForwarderData() model.AlertData { 33 | return model.AlertData{ 34 | Id: payload.FullRuleID(), 35 | State: payload.State, 36 | RuleURL: payload.RuleURL, 37 | RuleName: payload.RuleName, 38 | Message: payload.Message, 39 | Tags: payload.Tags, 40 | EvalMatches: []struct { 41 | Value float64 42 | Metric string 43 | Tags map[string]string 44 | }(payload.EvalMatches), 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/v0/payload_test.go: -------------------------------------------------------------------------------- 1 | package v0 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestAlertPayload(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | jsonData string 13 | want *alertPayload 14 | }{ 15 | { 16 | name: "GIVEN standard grafana alert payload WHEN converted to struct THEN data matches expected", 17 | jsonData: `{ 18 | "dashboardId": 13, 19 | "evalMatches": [ 20 | { 21 | "value": 1.4, 22 | "metric": "example.host:9100", 23 | "tags": { 24 | "__name__": "node_load1", 25 | "instance": "example.host:9100", 26 | "job": "node" 27 | } 28 | } 29 | ], 30 | "message": "This is a sample alert - please ignore", 31 | "orgId": 1, 32 | "panelId": 2, 33 | "ruleId": 26, 34 | "ruleName": "My Test Alert", 35 | "ruleUrl": "https://example.com/d/-IaNDf8Gz/testing-dashboard?tab=alert\\u0026viewPanel=2\\u0026orgId=1", 36 | "state": "alerting", 37 | "tags": { 38 | "priority": "high" 39 | }, 40 | "title": "[Alerting] My Test Alert" 41 | }`, 42 | want: &alertPayload{ 43 | Title: "[Alerting] My Test Alert", 44 | Message: "This is a sample alert - please ignore", 45 | State: "alerting", 46 | RuleName: "My Test Alert", 47 | RuleURL: "https://example.com/d/-IaNDf8Gz/testing-dashboard?tab=alert\\u0026viewPanel=2\\u0026orgId=1", 48 | RuleID: 26, 49 | OrgID: 1, 50 | DashboardID: 13, 51 | PanelID: 2, 52 | EvalMatches: []struct { 53 | Value float64 `json:"value"` 54 | Metric string `json:"metric"` 55 | Tags map[string]string `json:"tags"` 56 | }{ 57 | { 58 | Value: 1.4, 59 | Metric: "example.host:9100", 60 | Tags: map[string]string{"__name__": "node_load1", "instance": "example.host:9100", "job": "node"}, 61 | }, 62 | }, 63 | Tags: map[string]string{"priority": "high"}, 64 | }, 65 | }, 66 | } 67 | for _, tt := range tests { 68 | t.Run(tt.name, func(t *testing.T) { 69 | payload, err := convertJsonToPayload(tt.jsonData) 70 | if err != nil { 71 | t.Errorf("Failed to convert data - %v", err) 72 | } 73 | var payloadStr = fmt.Sprintf("%v", *payload) 74 | var wantStr = fmt.Sprintf("%v", *tt.want) 75 | if payloadStr != wantStr { 76 | t.Errorf("got %s, want %s", payloadStr, wantStr) 77 | } 78 | }) 79 | } 80 | } 81 | 82 | func convertJsonToPayload(jsonData string) (*alertPayload, error) { 83 | var alertPayload *alertPayload 84 | err := json.Unmarshal([]byte(jsonData), &alertPayload) 85 | return alertPayload, err 86 | } 87 | -------------------------------------------------------------------------------- /server/v1/handler.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "encoding/json" 5 | "grafana-matrix-forwarder/model" 6 | "grafana-matrix-forwarder/server/util" 7 | "net/http" 8 | ) 9 | 10 | type Handler struct { 11 | } 12 | 13 | func (h Handler) ParseRequest(request *http.Request, logPayload bool) (roomIDs []string, alerts []model.AlertData, err error) { 14 | bodyBytes, err := util.GetRequestBodyAsBytes(request) 15 | if err != nil { 16 | return 17 | } 18 | if logPayload { 19 | util.LogRequestPayload(request, bodyBytes) 20 | } 21 | 22 | roomIDs, err = util.GetRoomIDsFromURL(request.URL) 23 | if err != nil { 24 | return 25 | } 26 | 27 | payload, err := getAlertPayloadFromRequestBody(bodyBytes) 28 | if err != nil { 29 | return 30 | } 31 | 32 | alerts = payload.ToForwarderData() 33 | return 34 | } 35 | 36 | func getAlertPayloadFromRequestBody(bodyBytes []byte) (alertPayload alertPayload, err error) { 37 | err = json.Unmarshal(bodyBytes, &alertPayload) 38 | return 39 | } 40 | -------------------------------------------------------------------------------- /server/v1/payload.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "fmt" 5 | "grafana-matrix-forwarder/model" 6 | "strings" 7 | ) 8 | 9 | type alertPayload struct { 10 | Title string `json:"title"` 11 | Message string `json:"message"` 12 | State string `json:"state"` 13 | OrgID int `json:"orgId"` 14 | GroupLabels map[string]string `json:"groupLabels"` 15 | CommonLabels map[string]string `json:"commonLabels"` 16 | CommonAnnotations map[string]string `json:"commonAnnotations"` 17 | Alerts []alert `json:"alerts"` 18 | } 19 | 20 | type alert struct { 21 | Status string `json:"status"` 22 | Annotations map[string]string `json:"annotations"` 23 | Labels map[string]string `json:"labels"` 24 | DashboardUrl string `json:"dashboardURL"` 25 | PanelUrl string `json:"panelURL"` 26 | Fingerprint string `json:"fingerprint"` 27 | ValueString string `json:"valueString"` 28 | } 29 | 30 | // FullRuleID is defined as the combination of the OrgID, DashboardID, PanelID, and RuleID 31 | func fullRuleID(p alertPayload, a alert) string { 32 | return fmt.Sprintf("unified.%d.%s", p.OrgID, a.Fingerprint) 33 | } 34 | 35 | func normaliseStatus(status string) string { 36 | switch status { 37 | case "firing": 38 | return model.AlertStateAlerting 39 | case "resolved": 40 | return model.AlertStateResolved 41 | default: 42 | return status 43 | } 44 | } 45 | 46 | func (payload alertPayload) ToForwarderData() []model.AlertData { 47 | data := make([]model.AlertData, len(payload.Alerts)) 48 | for i, alert := range payload.Alerts { 49 | rawData := strings.ReplaceAll(alert.ValueString, "], [", "],\r\n[") 50 | data[i] = model.AlertData{ 51 | Id: fullRuleID(payload, alert), 52 | State: normaliseStatus(alert.Status), 53 | RuleURL: alert.PanelUrl, 54 | RuleName: alert.Labels["alertname"], 55 | Message: alert.Annotations["summary"], 56 | RawData: rawData, 57 | Tags: map[string]string{}, 58 | EvalMatches: []struct { 59 | Value float64 60 | Metric string 61 | Tags map[string]string 62 | }{}, 63 | } 64 | } 65 | return data 66 | } 67 | -------------------------------------------------------------------------------- /service/forwarder.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "grafana-matrix-forwarder/cfg" 5 | "grafana-matrix-forwarder/formatter" 6 | "grafana-matrix-forwarder/matrix" 7 | "grafana-matrix-forwarder/model" 8 | "log" 9 | ) 10 | 11 | type Forwarder struct { 12 | AppSettings cfg.AppSettings 13 | MatrixWriter matrix.Writer 14 | alertToSentEventMap map[string]sentMatrixEvent 15 | alertMapPersistenceEnabled bool 16 | } 17 | 18 | func NewForwarder(appSettings cfg.AppSettings, writer matrix.Writer) Forwarder { 19 | return Forwarder{ 20 | AppSettings: appSettings, 21 | MatrixWriter: writer, 22 | alertToSentEventMap: map[string]sentMatrixEvent{}, 23 | alertMapPersistenceEnabled: appSettings.PersistAlertMap, 24 | } 25 | } 26 | 27 | func (f *Forwarder) ForwardEvents(roomIds []string, alerts []model.AlertData) error { 28 | for _, id := range roomIds { 29 | for _, alert := range alerts { 30 | err := f.forwardSingleEvent(id, alert) 31 | if err != nil { 32 | return err 33 | } 34 | } 35 | } 36 | return nil 37 | } 38 | 39 | func (f *Forwarder) forwardSingleEvent(roomID string, alert model.AlertData) error { 40 | log.Printf("alert received (%s) - forwarding to room: %v", alert.Id, roomID) 41 | 42 | resolveWithReaction := f.AppSettings.ResolveMode == cfg.ResolveWithReaction 43 | resolveWithReply := f.AppSettings.ResolveMode == cfg.ResolveWithReply 44 | 45 | if sentEvent, ok := f.alertToSentEventMap[alert.Id]; ok { 46 | if alert.State == model.AlertStateResolved && resolveWithReaction { 47 | return f.sendResolvedReaction(roomID, sentEvent.EventID, alert) 48 | } 49 | if alert.State == model.AlertStateResolved && resolveWithReply { 50 | return f.sendResolvedReply(roomID, sentEvent, alert) 51 | } 52 | } 53 | return f.sendAlertMessage(roomID, alert) 54 | } 55 | 56 | func (f *Forwarder) sendResolvedReaction(roomID, eventID string, alert model.AlertData) error { 57 | reaction := formatter.GenerateReaction(alert) 58 | f.deleteMatrixEvent(alert.Id) 59 | _, err := f.MatrixWriter.React(roomID, eventID, reaction) 60 | return err 61 | } 62 | 63 | func (f *Forwarder) sendResolvedReply(roomID string, sentEvent sentMatrixEvent, alert model.AlertData) error { 64 | reply, err := formatter.GenerateReply(sentEvent.SentFormattedBody, alert) 65 | if err != nil { 66 | return err 67 | } 68 | f.deleteMatrixEvent(alert.Id) 69 | _, err = f.MatrixWriter.Reply(roomID, sentEvent.EventID, reply) 70 | return err 71 | } 72 | 73 | func (f *Forwarder) sendAlertMessage(roomID string, alert model.AlertData) error { 74 | message, err := formatter.GenerateMessage(alert, f.AppSettings.MetricRounding) 75 | if err != nil { 76 | return err 77 | } 78 | resp, err := f.MatrixWriter.Send(roomID, message) 79 | if err == nil { 80 | f.storeMatrixEvent(alert.Id, resp, message.HtmlBody) 81 | } 82 | return err 83 | } 84 | -------------------------------------------------------------------------------- /service/persistence.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | ) 9 | 10 | type sentMatrixEvent struct { 11 | EventID string 12 | SentFormattedBody string 13 | } 14 | 15 | const ( 16 | alertMapFileName = "grafanaToMatrixMap.json" 17 | ) 18 | 19 | func (f *Forwarder) storeMatrixEvent(alertID string, msgID, body string) { 20 | f.alertToSentEventMap[alertID] = sentMatrixEvent{ 21 | EventID: msgID, 22 | SentFormattedBody: body, 23 | } 24 | } 25 | 26 | func (f *Forwarder) deleteMatrixEvent(alertID string) { 27 | delete(f.alertToSentEventMap, alertID) 28 | } 29 | 30 | func (f *Forwarder) prePopulateAlertMap() { 31 | fileData, err := ioutil.ReadFile(alertMapFileName) 32 | if err == nil { 33 | err = json.Unmarshal(fileData, &f.alertToSentEventMap) 34 | } 35 | 36 | if err != nil { 37 | log.Printf("failed to load alert map - falling back on an empty map (%v)", err) 38 | } 39 | } 40 | 41 | func (f *Forwarder) persistAlertMap() { 42 | if !f.alertMapPersistenceEnabled { 43 | return 44 | } 45 | 46 | jsonData, err := json.Marshal(f.alertToSentEventMap) 47 | if err == nil { 48 | err = ioutil.WriteFile(alertMapFileName, jsonData, os.ModePerm) 49 | } 50 | 51 | if err != nil { 52 | log.Printf("failed to persist alert map - functionality disabled (%v)", err) 53 | f.alertMapPersistenceEnabled = false 54 | } 55 | } 56 | --------------------------------------------------------------------------------