├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── go.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── THIRD-PARTY-NOTICES ├── build-release-package.sh ├── dev ├── alertmanager-example.json ├── grafana-webhook-alert-example.json ├── slack-compatible-notification-example.json └── xmpp-dev-stack │ ├── docker-compose.yml │ ├── prosody │ ├── Dockerfile │ └── dev-server.sh │ └── recipient │ ├── .gitignore │ ├── Dockerfile │ ├── go.mod │ ├── go.sum │ └── main.go ├── go.mod ├── go.sum ├── handler.go ├── main.go ├── parser ├── alertmanager.go ├── common.go ├── grafana.go └── slack-compatible.go └── xmpp-webhook.service /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '42 14 * * 5' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | language: [ 'go' ] 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | 25 | - name: Initialize CodeQL 26 | uses: github/codeql-action/init@v1 27 | with: 28 | languages: ${{ matrix.language }} 29 | 30 | - name: Autobuild 31 | uses: github/codeql-action/autobuild@v1 32 | 33 | - name: Perform CodeQL Analysis 34 | uses: github/codeql-action/analyze@v1 35 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.15 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | xmpp-webhook 4 | xmpp-webhook-*.tar.gz* 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15-alpine3.13 as builder 2 | MAINTAINER Thomas Maier 3 | RUN apk add --no-cache git 4 | COPY . /build 5 | WORKDIR /build 6 | RUN GOOS=linux GOARCH=amd64 go build 7 | 8 | FROM alpine:3.13 9 | RUN apk add --no-cache ca-certificates 10 | COPY --from=builder /build/xmpp-webhook /xmpp-webhook 11 | RUN adduser -D -g '' xmpp-webhook 12 | USER xmpp-webhook 13 | ENV XMPP_ID="" \ 14 | XMPP_PASS="" \ 15 | XMPP_RECIPIENTS="" 16 | EXPOSE 4321 17 | CMD ["/xmpp-webhook"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Thomas Maier 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xmpp-webhook 2 | - Multipurpose XMPP-Webhook (Built for DevOps Alerts) 3 | - Based on https://github.com/mellium/xmpp 4 | 5 | ## Status 6 | `xmpp-webhook` currently support: 7 | 8 | - Grafana Webhook alerts 9 | - Alertmanager Webhooks 10 | - Slack Incoming Webhooks (Feedback appreciated) 11 | 12 | Check https://github.com/tmsmr/xmpp-webhook/blob/master/parser/ to learn how to support more source services. 13 | 14 | ## Usage 15 | - `xmpp-webhook` is configured via environment variables: 16 | - `XMPP_ID` - The JID we want to use 17 | - `XMPP_PASS` - The password 18 | - `XMPP_RECIPIENTS` - Comma-separated list of JID's 19 | - `XMPP_SKIP_VERIFY` - Skip TLS verification (Optional) 20 | - `XMPP_OVER_TLS` - Use dedicated TLS port (Optional) 21 | - `XMPP_WEBHOOK_LISTEN_ADDRESS` - Bind address (Optional) 22 | - After startup, `xmpp-webhook` tries to connect to the XMPP server and provides the implemented HTTP enpoints. e.g.: 23 | 24 | ``` 25 | curl -X POST -d @dev/grafana-webhook-alert-example.json localhost:4321/grafana 26 | curl -X POST -d @dev/alertmanager-example.json localhost:4321/alertmanager 27 | curl -X POST -d @dev/slack-compatible-notification-example.json localhost:4321/slack 28 | ``` 29 | - After parsing the body in the appropriate `parserFunc`, the notification is then distributed to the configured recipients. 30 | 31 | ## Run with Docker 32 | ### Build it 33 | - Build image: `docker build -t xmpp-webhook .` 34 | - Run: `docker run -e "XMPP_ID=alerts@example.org" -e "XMPP_PASS=xxx" -e "XMPP_RECIPIENTS=a@example.org,b@example.org" -p 4321:4321 -d --name xmpp-webhook xmpp-webhook` 35 | ### Use prebuilt image from Docker Hub 36 | - Run: `docker run -e "XMPP_ID=alerts@example.org" -e "XMPP_PASS=xxx" -e "XMPP_RECIPIENTS=a@example.org,b@example.org" -p 4321:4321 -d --name xmpp-webhook tmsmr/xmpp-webhook:latest` 37 | 38 | ## Installation 39 | - Download and extract the latest tarball (GitHub release page) 40 | - Install the binary: `install -D -m 744 xmpp-webhook /usr/local/bin/xmpp-webhook` 41 | - Install the service: `install -D -m 644 xmpp-webhook.service /etc/systemd/system/xmpp-webhook.service` 42 | - Configure XMPP credentials in `/etc/xmpp-webhook.env`. e.g.: 43 | 44 | ``` 45 | XMPP_ID='bot@example.com' 46 | XMPP_PASS='passw0rd' 47 | XMPP_RECIPIENTS='jdoe@example.com,ops@example.com' 48 | ``` 49 | 50 | - Enable and start the service: 51 | 52 | ``` 53 | systemctl daemon-reload 54 | systemctl enable xmpp-webhook 55 | systemctl start xmpp-webhook 56 | ``` 57 | 58 | ## Building 59 | - Dependencies are managed via Go Modules (https://github.com/golang/go/wiki/Modules). 60 | - Clone the sources 61 | - Change in the project folder: 62 | - Build `xmpp-webhook`: `go build` 63 | - `dev/xmpp-dev-stack` starts Prosody (With "auth_any" and "roster_allinall" enabled) and two XMPP-clients for easy testing 64 | 65 | ## Need help? 66 | Feel free to contact me! 67 | -------------------------------------------------------------------------------- /THIRD-PARTY-NOTICES: -------------------------------------------------------------------------------- 1 | xmpp-webhook uses third-party libraries or other resources that may 2 | be distributed under licenses different than the xmpp-webhook software. 3 | 4 | 1) License Notice for https://github.com/mellium/xmpp 5 | --------------------------- 6 | 7 | Copyright © 2014 The Mellium Contributors. 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | 13 | 1. Redistributions of source code must retain the above copyright notice, this 14 | list of conditions and the following disclaimer. 15 | 16 | 2. Redistributions in binary form must reproduce the above copyright notice, 17 | this list of conditions and the following disclaimer in the documentation 18 | and/or other materials provided with the distribution. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /build-release-package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -xe 4 | 5 | git checkout "$1" 6 | docker run --rm -ti -v "$(pwd)":/build golang:1.15-buster sh -c "cd /build && go build" 7 | tar -czvf "xmpp-webhook-$1-linux-amd64.tar.gz" xmpp-webhook xmpp-webhook.service README.md LICENSE THIRD-PARTY-NOTICES 8 | sha512sum "xmpp-webhook-$1-linux-amd64.tar.gz" > "xmpp-webhook-$1-linux-amd64.tar.gz.sha512" 9 | -------------------------------------------------------------------------------- /dev/alertmanager-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "receiver": "xmpp-email", 3 | "status": "firing", 4 | "alerts": [ 5 | { 6 | "status": "firing", 7 | "labels": { 8 | "alertname": "testalert", 9 | "instance": "test.net", 10 | "severity": "critical" 11 | }, 12 | "annotations": { "summary": "Simple test" }, 13 | "startsAt": "2021-02-27T18:38:56Z", 14 | "endsAt": "0001-01-01T00:00:00Z", 15 | "generatorURL": "http://local-example-alert/testalert", 16 | "fingerprint": "d4baf2738cfc5a30" 17 | } 18 | ], 19 | "groupLabels": { "instance": "test.net", "severity": "critical" }, 20 | "commonLabels": { 21 | "alertname": "testalert", 22 | "instance": "test.net", 23 | "severity": "critical" 24 | }, 25 | "commonAnnotations": { "summary": "Simple test" }, 26 | "externalURL": "http://127.0.0.1:9093", 27 | "version": "4", 28 | "groupKey": "{}/{severity=\"critical\"}:{instance=\"test.net\", severity=\"critical\"}", 29 | "truncatedAlerts": 0 30 | } 31 | -------------------------------------------------------------------------------- /dev/grafana-webhook-alert-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "My alert", 3 | "ruleId": 1, 4 | "ruleName": "Load peaking!", 5 | "ruleUrl": "http://url.to.grafana/db/dashboard/my_dashboard?panelId=2", 6 | "state": "alerting", 7 | "imageUrl": "http://s3.image.url", 8 | "message": "Load is peaking. Make sure the traffic is real and spin up more webfronts", 9 | "evalMatches": [ 10 | { 11 | "metric": "requests", 12 | "tags": {}, 13 | "value": 122 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /dev/slack-compatible-notification-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "channel": "#channel", 3 | "icon_emoji": ":heart:", 4 | "username": "Flux Deployer", 5 | "attachments": [ 6 | { 7 | "color": "#4286f4", 8 | "title": "Applied flux changes to cluster", 9 | "title_link": "https://GITURL/USERNAME/kubernetes/commit/COMMITSHA", 10 | "text": "Event: Sync: 0f34755, jabber:deployment/test\nCommits:\n\n* \u003chttps://GITURL/USERNAME/kubernetes/commit/COMMITSHA\u003e: change test to test webhook\n\nResources updated:\n\n* jabber:deployment/test" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /dev/xmpp-dev-stack/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | prosody: 4 | build: 5 | context: ./prosody 6 | network_mode: host 7 | recipient-a: 8 | build: 9 | context: ./recipient 10 | network_mode: host 11 | depends_on: 12 | - prosody 13 | restart: always 14 | environment: 15 | - XMPP_ID=recipient-a@localhost 16 | - XMPP_PASS=insecure 17 | recipient-b: 18 | build: 19 | context: ./recipient 20 | network_mode: host 21 | depends_on: 22 | - prosody 23 | restart: always 24 | environment: 25 | - XMPP_ID=recipient-b@localhost 26 | - XMPP_PASS=insecure 27 | -------------------------------------------------------------------------------- /dev/xmpp-dev-stack/prosody/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM unclev/prosody-docker-extended:0.11 2 | 3 | ADD ./dev-server.sh /dev-server.sh 4 | 5 | ENTRYPOINT ["/dev-server.sh"] 6 | CMD ["prosody"] 7 | -------------------------------------------------------------------------------- /dev/xmpp-dev-stack/prosody/dev-server.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # applies https://modules.prosody.im/mod_auth_any.html and https://modules.prosody.im/mod_roster_allinall.html for testing 4 | 5 | PROSODY_CONF=/etc/prosody/prosody.cfg.lua 6 | AUTH_ANY='authentication = "any"' 7 | MODULES='modules_enabled = { "auth_any", "roster_allinall" }' 8 | 9 | function changes_missing { 10 | if grep -q "$AUTH_ANY" $PROSODY_CONF; then 11 | return 1 12 | else 13 | return 0 14 | fi 15 | } 16 | 17 | function apply_changes { 18 | echo "$AUTH_ANY" >> $PROSODY_CONF 19 | echo "$MODULES" >> $PROSODY_CONF 20 | } 21 | 22 | if changes_missing; then 23 | apply_changes 24 | fi 25 | 26 | /usr/bin/entrypoint.sh "$@" 27 | -------------------------------------------------------------------------------- /dev/xmpp-dev-stack/recipient/.gitignore: -------------------------------------------------------------------------------- 1 | recipient 2 | -------------------------------------------------------------------------------- /dev/xmpp-dev-stack/recipient/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15-alpine3.13 as builder 2 | RUN apk add --no-cache git 3 | COPY . /build 4 | WORKDIR /build 5 | RUN go build 6 | 7 | FROM alpine:3.13 8 | COPY --from=builder /build/recipient /recipient 9 | CMD /recipient 10 | -------------------------------------------------------------------------------- /dev/xmpp-dev-stack/recipient/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tmsmr/xmpp-webhook/dev/xmpp-dev-stack/recipient 2 | 3 | go 1.15 4 | 5 | require ( 6 | mellium.im/sasl v0.2.1 7 | mellium.im/xmlstream v0.15.2 8 | mellium.im/xmpp v0.18.0 9 | ) 10 | -------------------------------------------------------------------------------- /dev/xmpp-dev-stack/recipient/go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 2 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 3 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= 4 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 5 | golang.org/x/image v0.0.0-20181116024801-cd38e8056d9b/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= 6 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 7 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7 h1:fHDIZ2oxGnUZRN6WgWFCbYBjH9uqVPRCUVUDhs0wnbA= 8 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 9 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 10 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 11 | golang.org/x/sys v0.0.0-20210110051926-789bb1bd4061/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 12 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 13 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 14 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 15 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 16 | mellium.im/reader v0.1.0 h1:UUEMev16gdvaxxZC7fC08j7IzuDKh310nB6BlwnxTww= 17 | mellium.im/reader v0.1.0/go.mod h1:F+X5HXpkIfJ9EE1zHQG9lM/hO946iYAmU7xjg5dsQHI= 18 | mellium.im/sasl v0.2.1 h1:nspKSRg7/SyO0cRGY71OkfHab8tf9kCts6a6oTDut0w= 19 | mellium.im/sasl v0.2.1/go.mod h1:ROaEDLQNuf9vjKqE1SrAfnsobm2YKXT1gnN1uDp1PjQ= 20 | mellium.im/xmlstream v0.15.2-0.20201219131358-a51cc5cf8151/go.mod h1:7SUlP7f2qnMczK+Cu/OFgqaIhldMolVjo8np7xG41D0= 21 | mellium.im/xmlstream v0.15.2 h1:RleOK10lEsVtzpEZsJeRl4Iu0iC5SQnTQIGJZ7ZHGEc= 22 | mellium.im/xmlstream v0.15.2/go.mod h1:7SUlP7f2qnMczK+Cu/OFgqaIhldMolVjo8np7xG41D0= 23 | mellium.im/xmpp v0.18.0 h1:mm4zgK+7XkVpOKrM6d7d9ssmvH9Z67+16ODU9Rx4fqU= 24 | mellium.im/xmpp v0.18.0/go.mod h1:T1xCJIP9JyIIO4SSLlfj6zUi/58g22rFL6eojGwlJig= 25 | -------------------------------------------------------------------------------- /dev/xmpp-dev-stack/recipient/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "encoding/xml" 7 | "fmt" 8 | "io" 9 | "log" 10 | "mellium.im/sasl" 11 | "mellium.im/xmlstream" 12 | "mellium.im/xmpp" 13 | "mellium.im/xmpp/dial" 14 | "mellium.im/xmpp/jid" 15 | "mellium.im/xmpp/stanza" 16 | "os" 17 | ) 18 | 19 | func panicOnErr(err error) { 20 | if err != nil { 21 | panic(err) 22 | } 23 | } 24 | 25 | type MessageBody struct { 26 | stanza.Message 27 | Body string `xml:"body"` 28 | } 29 | 30 | func main() { 31 | xi := os.Getenv("XMPP_ID") 32 | xp := os.Getenv("XMPP_PASS") 33 | 34 | if xi == "" || xp == "" { 35 | log.Fatal("XMPP_ID, XMPP_PASS not set") 36 | } 37 | 38 | address, err := jid.Parse(xi) 39 | panicOnErr(err) 40 | 41 | var dialer = dial.Dialer{NoTLS: true} 42 | conn, err := dialer.Dial(context.TODO(), "tcp", address) 43 | panicOnErr(err) 44 | 45 | tlsConfig := tls.Config{InsecureSkipVerify: true} 46 | 47 | session, err := xmpp.NewSession( 48 | context.TODO(), 49 | address.Domain(), 50 | address, 51 | conn, 52 | 0, 53 | xmpp.NewNegotiator(xmpp.StreamConfig{Features: func(_ *xmpp.Session, f ...xmpp.StreamFeature) []xmpp.StreamFeature { 54 | if f != nil { 55 | return f 56 | } 57 | return []xmpp.StreamFeature{ 58 | xmpp.BindResource(), 59 | xmpp.StartTLS(&tlsConfig), 60 | xmpp.SASL("", xp, sasl.ScramSha256Plus, sasl.ScramSha256, sasl.ScramSha1Plus, sasl.ScramSha1, sasl.Plain), 61 | } 62 | }}), 63 | ) 64 | panicOnErr(err) 65 | 66 | fmt.Println("connected") 67 | 68 | err = session.Send(context.TODO(), stanza.Presence{Type: stanza.AvailablePresence}.Wrap(nil)) 69 | panicOnErr(err) 70 | 71 | err = session.Serve(xmpp.HandlerFunc(func(t xmlstream.TokenReadEncoder, start *xml.StartElement) error { 72 | d := xml.NewTokenDecoder(t) 73 | if start.Name.Local != "message" { 74 | return nil 75 | } 76 | 77 | msg := MessageBody{} 78 | err = d.DecodeElement(&msg, start) 79 | if err != nil && err != io.EOF { 80 | return nil 81 | } 82 | 83 | if msg.Body == "" || msg.Type != stanza.ChatMessage { 84 | return nil 85 | } 86 | 87 | fmt.Printf("%s: %s\n", msg.From, msg.Body) 88 | 89 | return nil 90 | })) 91 | panicOnErr(err) 92 | } 93 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tmsmr/xmpp-webhook 2 | 3 | require ( 4 | golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 // indirect 5 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect 6 | golang.org/x/text v0.3.5 // indirect 7 | mellium.im/sasl v0.2.2-0.20190711145101-7aedd692081c 8 | mellium.im/xmlstream v0.15.2 9 | mellium.im/xmpp v0.18.0 10 | ) 11 | 12 | go 1.15 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 2 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 3 | golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 4 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 5 | golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 h1:/ZScEX8SfEmUGRHs0gxpqteO5nfNW6axyZbBdw9A12g= 6 | golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 7 | golang.org/x/image v0.0.0-20181116024801-cd38e8056d9b/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= 8 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 9 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 10 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= 11 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 12 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 13 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 14 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 15 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 16 | golang.org/x/sys v0.0.0-20210110051926-789bb1bd4061/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 18 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 19 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 20 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 21 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 22 | golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= 23 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 24 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 25 | mellium.im/reader v0.1.0 h1:UUEMev16gdvaxxZC7fC08j7IzuDKh310nB6BlwnxTww= 26 | mellium.im/reader v0.1.0/go.mod h1:F+X5HXpkIfJ9EE1zHQG9lM/hO946iYAmU7xjg5dsQHI= 27 | mellium.im/sasl v0.2.1/go.mod h1:ROaEDLQNuf9vjKqE1SrAfnsobm2YKXT1gnN1uDp1PjQ= 28 | mellium.im/sasl v0.2.2-0.20190711145101-7aedd692081c h1:NjXK0TtVdkGhEghhYh3eA/f5nR/VRRrW1MrEEp1rq90= 29 | mellium.im/sasl v0.2.2-0.20190711145101-7aedd692081c/go.mod h1:rTgGBJL0QZ3h4jRSAXRDTRB7h1b8GyyFpEG2t9Tp9ws= 30 | mellium.im/xmlstream v0.15.2-0.20201219131358-a51cc5cf8151/go.mod h1:7SUlP7f2qnMczK+Cu/OFgqaIhldMolVjo8np7xG41D0= 31 | mellium.im/xmlstream v0.15.2 h1:RleOK10lEsVtzpEZsJeRl4Iu0iC5SQnTQIGJZ7ZHGEc= 32 | mellium.im/xmlstream v0.15.2/go.mod h1:7SUlP7f2qnMczK+Cu/OFgqaIhldMolVjo8np7xG41D0= 33 | mellium.im/xmpp v0.18.0 h1:mm4zgK+7XkVpOKrM6d7d9ssmvH9Z67+16ODU9Rx4fqU= 34 | mellium.im/xmpp v0.18.0/go.mod h1:T1xCJIP9JyIIO4SSLlfj6zUi/58g22rFL6eojGwlJig= 35 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // interface for parser functions 8 | type parserFunc func(*http.Request) (string, error) 9 | 10 | type messageHandler struct { 11 | messages chan<- string // chan to xmpp client 12 | parserFunc parserFunc 13 | } 14 | 15 | // http request handler 16 | func (h *messageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 17 | // parse/generate message from http request 18 | m, err := h.parserFunc(r) 19 | if err != nil { 20 | w.WriteHeader(http.StatusInternalServerError) 21 | _, _ = w.Write([]byte(err.Error())) 22 | } else { 23 | // send message to xmpp client 24 | h.messages <- m 25 | w.WriteHeader(http.StatusOK) 26 | _, _ = w.Write([]byte("ok")) 27 | } 28 | } 29 | 30 | // returns new handler with a given parser function 31 | func newMessageHandler(m chan<- string, f parserFunc) *messageHandler { 32 | return &messageHandler{ 33 | messages: m, 34 | parserFunc: f, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "encoding/xml" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | 13 | "github.com/tmsmr/xmpp-webhook/parser" 14 | "mellium.im/sasl" 15 | "mellium.im/xmlstream" 16 | "mellium.im/xmpp" 17 | "mellium.im/xmpp/dial" 18 | "mellium.im/xmpp/jid" 19 | "mellium.im/xmpp/stanza" 20 | ) 21 | 22 | func panicOnErr(err error) { 23 | if err != nil { 24 | panic(err) 25 | } 26 | } 27 | 28 | type MessageBody struct { 29 | stanza.Message 30 | Body string `xml:"body"` 31 | } 32 | 33 | func initXMPP(address jid.JID, pass string, skipTLSVerify bool, useXMPPS bool) (*xmpp.Session, error) { 34 | tlsConfig := tls.Config{InsecureSkipVerify: skipTLSVerify} 35 | var dialer dial.Dialer 36 | // only use the tls config for the dialer if necessary 37 | if skipTLSVerify { 38 | dialer = dial.Dialer{NoTLS: !useXMPPS, TLSConfig: &tlsConfig} 39 | } else { 40 | dialer = dial.Dialer{NoTLS: !useXMPPS} 41 | } 42 | conn, err := dialer.Dial(context.TODO(), "tcp", address) 43 | if err != nil { 44 | return nil, err 45 | } 46 | // we need the domain in the tls config if we want to verify the cert 47 | if !skipTLSVerify { 48 | tlsConfig.ServerName = address.Domainpart() 49 | } 50 | return xmpp.NewSession( 51 | context.TODO(), 52 | address.Domain(), 53 | address, 54 | conn, 55 | 0, 56 | xmpp.NewNegotiator(xmpp.StreamConfig{Features: func(_ *xmpp.Session, f ...xmpp.StreamFeature) []xmpp.StreamFeature { 57 | if f != nil { 58 | return f 59 | } 60 | return []xmpp.StreamFeature{ 61 | xmpp.BindResource(), 62 | xmpp.StartTLS(&tlsConfig), 63 | xmpp.SASL("", pass, sasl.ScramSha256Plus, sasl.ScramSha256, sasl.ScramSha1Plus, sasl.ScramSha1, sasl.Plain), 64 | } 65 | }}), 66 | ) 67 | } 68 | 69 | func closeXMPP(session *xmpp.Session) { 70 | _ = session.Close() 71 | _ = session.Conn().Close() 72 | } 73 | 74 | func main() { 75 | // get xmpp credentials, message recipients 76 | xi := os.Getenv("XMPP_ID") 77 | xp := os.Getenv("XMPP_PASS") 78 | xr := os.Getenv("XMPP_RECIPIENTS") 79 | 80 | // get tls settings from env 81 | _, skipTLSVerify := os.LookupEnv("XMPP_SKIP_VERIFY") 82 | _, useXMPPS := os.LookupEnv("XMPP_OVER_TLS") 83 | 84 | // get listen address 85 | listenAddress := os.Getenv("XMPP_WEBHOOK_LISTEN_ADDRESS") 86 | if len(listenAddress) == 0 { 87 | listenAddress = ":4321" 88 | } 89 | 90 | // check if xmpp credentials and recipient list are supplied 91 | if xi == "" || xp == "" || xr == "" { 92 | log.Fatal("XMPP_ID, XMPP_PASS or XMPP_RECIPIENTS not set") 93 | } 94 | 95 | myjid, err := jid.Parse(xi) 96 | panicOnErr(err) 97 | 98 | // connect to xmpp server 99 | xmppSession, err := initXMPP(myjid, xp, skipTLSVerify, useXMPPS) 100 | panicOnErr(err) 101 | defer closeXMPP(xmppSession) 102 | 103 | // send initial presence 104 | panicOnErr(xmppSession.Send(context.TODO(), stanza.Presence{Type: stanza.AvailablePresence}.Wrap(nil))) 105 | 106 | // listen for messages and echo them 107 | go func() { 108 | err = xmppSession.Serve(xmpp.HandlerFunc(func(t xmlstream.TokenReadEncoder, start *xml.StartElement) error { 109 | d := xml.NewTokenDecoder(t) 110 | // ignore elements that aren't messages 111 | if start.Name.Local != "message" { 112 | return nil 113 | } 114 | 115 | // parse message into struct 116 | msg := MessageBody{} 117 | err = d.DecodeElement(&msg, start) 118 | if err != nil && err != io.EOF { 119 | return nil 120 | } 121 | 122 | // ignore empty messages and stanzas that aren't messages 123 | if msg.Body == "" || msg.Type != stanza.ChatMessage { 124 | return nil 125 | } 126 | 127 | // create reply with identical contents 128 | reply := MessageBody{ 129 | Message: stanza.Message{ 130 | To: msg.From.Bare(), 131 | From: myjid, 132 | Type: stanza.ChatMessage, 133 | }, 134 | Body: msg.Body, 135 | } 136 | 137 | // try to send reply, ignore errors 138 | _ = t.Encode(reply) 139 | return nil 140 | })) 141 | panicOnErr(err) 142 | }() 143 | 144 | // create chan for messages (webhooks -> xmpp) 145 | messages := make(chan string) 146 | 147 | ctx, cancel := context.WithCancel(context.Background()) 148 | defer cancel() 149 | 150 | // wait for messages from the webhooks and send them to all recipients 151 | go func() { 152 | for m := range messages { 153 | for _, r := range strings.Split(xr, ",") { 154 | recipient, err := jid.Parse(r) 155 | panicOnErr(err) 156 | // try to send message, ignore errors 157 | _ = xmppSession.Encode(ctx, MessageBody{ 158 | Message: stanza.Message{ 159 | To: recipient, 160 | From: myjid, 161 | Type: stanza.ChatMessage, 162 | }, 163 | Body: m, 164 | }) 165 | } 166 | } 167 | }() 168 | 169 | // initialize handlers with associated parser functions 170 | http.Handle("/grafana", newMessageHandler(messages, parser.GrafanaParserFunc)) 171 | http.Handle("/slack", newMessageHandler(messages, parser.SlackParserFunc)) 172 | http.Handle("/alertmanager", newMessageHandler(messages, parser.AlertmanagerParserFunc)) 173 | 174 | // listen for requests 175 | _ = http.ListenAndServe(listenAddress, nil) 176 | } 177 | -------------------------------------------------------------------------------- /parser/alertmanager.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | ) 10 | 11 | func AlertmanagerParserFunc(r *http.Request) (string, error) { 12 | // get alert data from request 13 | body, err := ioutil.ReadAll(r.Body) 14 | if err != nil { 15 | return "", errors.New(readErr) 16 | } 17 | 18 | payload := &struct { 19 | Alerts []struct { 20 | Status string `json:"status"` 21 | Labels map[string]string `json:"labels"` 22 | Annotations map[string]string `json:"annotations"` 23 | } `json:"alerts"` 24 | }{} 25 | 26 | // parse body into the alert struct 27 | err = json.Unmarshal(body, &payload) 28 | if err != nil { 29 | return "", errors.New(parseErr) 30 | } 31 | 32 | // construct alert message 33 | var message string 34 | for _, alert := range payload.Alerts { 35 | if alert.Status == "resolved" { 36 | message = "Resolved" + "\n" 37 | } else { 38 | message = "Firing" + "\n" 39 | } 40 | 41 | message += "Labels" + "\n" 42 | for key, label := range alert.Labels { 43 | message += fmt.Sprintf("%s = %s\n", key, label) 44 | } 45 | 46 | message += "Annotations" + "\n" 47 | for key, annotation := range alert.Annotations { 48 | message += fmt.Sprintf("%s = %s\n", key, annotation) 49 | } 50 | 51 | message += "\n" 52 | } 53 | 54 | return message, nil 55 | } 56 | -------------------------------------------------------------------------------- /parser/common.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | const readErr string = "failed to read alert body" 4 | const parseErr string = "failed to parse alert body" 5 | -------------------------------------------------------------------------------- /parser/grafana.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | func GrafanaParserFunc(r *http.Request) (string, error) { 11 | // get alert data from request 12 | body, err := ioutil.ReadAll(r.Body) 13 | if err != nil { 14 | return "", errors.New(readErr) 15 | } 16 | 17 | alert := &struct { 18 | Title string `json:"title"` 19 | RuleURL string `json:"ruleUrl"` 20 | State string `json:"state"` 21 | Message string `json:"message"` 22 | }{} 23 | 24 | // parse body into the alert struct 25 | err = json.Unmarshal(body, &alert) 26 | if err != nil { 27 | return "", errors.New(parseErr) 28 | } 29 | 30 | // construct alert message 31 | var message string 32 | switch alert.State { 33 | case "ok": 34 | message = ":) " + alert.Title 35 | default: 36 | message = ":( " + alert.Title + "\n\n" 37 | message += alert.Message + "\n\n" 38 | message += alert.RuleURL 39 | } 40 | 41 | return message, nil 42 | } 43 | -------------------------------------------------------------------------------- /parser/slack-compatible.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | func SlackParserFunc(r *http.Request) (string, error) { 11 | // get alert data from request 12 | body, err := ioutil.ReadAll(r.Body) 13 | if err != nil { 14 | return "", errors.New(readErr) 15 | } 16 | 17 | alert := struct { 18 | Text string `json:"text"` 19 | Attachments []struct { 20 | Title string `json:"title"` 21 | TitleLink string `json:"title_link"` 22 | Text string `json:"text"` 23 | } `json:"attachments"` 24 | }{} 25 | 26 | // parse body into the alert struct 27 | err = json.Unmarshal(body, &alert) 28 | if err != nil { 29 | return "", errors.New(parseErr) 30 | } 31 | 32 | // construct alert message 33 | message := alert.Text 34 | for _, attachment := range alert.Attachments { 35 | if len(message) > 0 { 36 | message = message + "\n" 37 | } 38 | message += attachment.Title + "\n" 39 | message += attachment.TitleLink + "\n\n" 40 | message += attachment.Text 41 | } 42 | 43 | return message, nil 44 | } 45 | -------------------------------------------------------------------------------- /xmpp-webhook.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=XMPP-Webhook 3 | After=network.target 4 | 5 | [Service] 6 | EnvironmentFile=/etc/xmpp-webhook.env 7 | ExecStart=/usr/local/bin/xmpp-webhook 8 | Restart=always 9 | RestartSec=30 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | --------------------------------------------------------------------------------