├── .drone.yml ├── .env.example ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docker-entrypoint.sh ├── requirements.txt └── src ├── E2EEClient.py ├── WebhookServer.py └── main.py /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | type: docker 4 | name: build-main 5 | 6 | clone: 7 | depth: 50 8 | 9 | platform: 10 | os: linux 11 | arch: amd64 12 | 13 | steps: 14 | 15 | # @see: http://plugins.drone.io/drone-plugins/drone-docker/ 16 | # @see: https://github.com/drone-plugins/drone-docker 17 | - name: build-harbor 18 | image: plugins/docker 19 | settings: 20 | registry: 21 | from_secret: harbor_registry_fqdn 22 | repo: 23 | from_secret: harbor_repo 24 | username: 25 | from_secret: harbor_robot_matrixe2ee_push_username 26 | password: 27 | from_secret: harbor_robot_matrixe2ee_push_password 28 | dockerfile: Dockerfile 29 | tags: 30 | - main 31 | 32 | # @see: http://plugins.drone.io/drone-plugins/drone-docker/ 33 | # @see: https://github.com/drone-plugins/drone-docker 34 | - name: build-dockerhub 35 | image: plugins/docker 36 | settings: 37 | repo: immanuelfodor/matrix-e2ee-webhook 38 | username: immanuelfodor 39 | password: 40 | from_secret: dockerhub_matrixe2ee_access_token 41 | dockerfile: Dockerfile 42 | tags: 43 | - main 44 | 45 | # @see: http://plugins.drone.io/drone-plugins/drone-webhook/ 46 | # @see: https://keel.sh/docs/#webhooks 47 | - name: deploy-k8s 48 | image: plugins/webhook 49 | settings: 50 | urls: 51 | from_secret: keel_webhook_url 52 | content_type: application/json 53 | template: 54 | from_secret: keel_native_payload_repo_tag_master 55 | 56 | # @see: http://plugins.drone.io/drone-plugins/drone-webhook/ 57 | - name: notify-done 58 | image: plugins/webhook 59 | failure: ignore 60 | settings: 61 | urls: 62 | from_secret: done_webhook_url 63 | content_type: application/json 64 | template: | 65 | { 66 | "build": "{{ build.status }} @ {{ repo.owner }}/{{ repo.name }} on branch {{ build.branch }}", 67 | "url": "{{ build.link }}" 68 | } 69 | 70 | trigger: 71 | branch: 72 | - main 73 | event: 74 | - push 75 | 76 | --- 77 | kind: pipeline 78 | type: docker 79 | name: build-release 80 | 81 | clone: 82 | depth: 50 83 | 84 | platform: 85 | os: linux 86 | arch: amd64 87 | 88 | steps: 89 | 90 | # @see: http://plugins.drone.io/drone-plugins/drone-docker/ 91 | # @see: https://github.com/drone-plugins/drone-docker 92 | - name: build-dockerhub 93 | image: plugins/docker 94 | settings: 95 | repo: immanuelfodor/matrix-e2ee-webhook 96 | username: immanuelfodor 97 | password: 98 | from_secret: dockerhub_matrixe2ee_access_token 99 | dockerfile: Dockerfile 100 | auto_tag: true 101 | 102 | # @see: http://plugins.drone.io/drone-plugins/drone-webhook/ 103 | - name: notify-done 104 | image: plugins/webhook 105 | failure: ignore 106 | settings: 107 | urls: 108 | from_secret: done_webhook_url 109 | content_type: application/json 110 | template: | 111 | { 112 | "build": "{{ build.status }} @ {{ repo.owner }}/{{ repo.name }} on branch {{ build.branch }}", 113 | "url": "{{ build.link }}" 114 | } 115 | 116 | trigger: 117 | event: 118 | - tag 119 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MESSAGE_FORMAT=yaml 2 | USE_MARKDOWN=False 3 | ALLOW_UNICODE=True 4 | DISPLAY_APP_NAME=True 5 | 6 | MATRIX_SERVER=https://matrix.example.org 7 | MATRIX_SSLVERIFY=True 8 | MATRIX_USERID=@myhook:matrix.example.org 9 | MATRIX_PASSWORD='mypass+*!word' 10 | MATRIX_DEVICE=any-device-name-eg-docker 11 | MATRIX_ADMIN_ROOM=!privatechatwiththebotuser:matrix.example.org 12 | 13 | KNOWN_TOKENS='YOURSECRETTOKEN,!myroomid:matrix.example.org,Curl anOTheRToKen99,!myroomid2:matrix.example.org,Grafana' 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,virtualenv,dotenv 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,virtualenv,dotenv 4 | 5 | ### dotenv ### 6 | .env 7 | 8 | ### Python ### 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | pip-wheel-metadata/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | pytestdebug.log 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | doc/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | .python-version 96 | 97 | # pipenv 98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 101 | # install all needed dependencies. 102 | #Pipfile.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .venv 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | pythonenv* 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # profiling data 145 | .prof 146 | 147 | ### VirtualEnv ### 148 | # Virtualenv 149 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 150 | [Bb]in 151 | [Ii]nclude 152 | [Ll]ib 153 | [Ll]ib64 154 | [Ll]ocal 155 | [Ss]cripts 156 | pyvenv.cfg 157 | pip-selfcheck.json 158 | 159 | ### VisualStudioCode ### 160 | .vscode/* 161 | !.vscode/settings.json 162 | !.vscode/tasks.json 163 | !.vscode/launch.json 164 | !.vscode/extensions.json 165 | *.code-workspace 166 | 167 | ### VisualStudioCode Patch ### 168 | # Ignore all local history of files 169 | .history 170 | .ionide 171 | 172 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,virtualenv,dotenv 173 | 174 | # local login store for development 175 | store/ 176 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.venvPath": "./venv", 3 | "python.pythonPath": "./venv/bin/python3.8", 4 | "python.linting.pylintPath": "./venv/bin/pylint" 5 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt ./ 6 | 7 | # build dependencies 8 | RUN set -x \ 9 | && apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev olm-dev \ 10 | && pip install --no-cache-dir -r requirements.txt \ 11 | && apk del .build-deps 12 | 13 | # runtime dependeincies 14 | RUN set -x \ 15 | && apk add --no-cache olm 16 | 17 | EXPOSE 8000 18 | 19 | ENV PYTHONUNBUFFERED=1 20 | ENV LOGIN_STORE_PATH=/config 21 | 22 | ENV MESSAGE_FORMAT=yaml 23 | ENV USE_MARKDOWN=False 24 | ENV ALLOW_UNICODE=True 25 | ENV DISPLAY_APP_NAME=True 26 | 27 | ENV MATRIX_SERVER=https://matrix.example.org 28 | ENV MATRIX_SSLVERIFY=True 29 | ENV MATRIX_USERID=@myhook:matrix.example.org 30 | ENV MATRIX_PASSWORD=mypassword 31 | ENV MATRIX_DEVICE=any-device-name 32 | ENV MATRIX_ADMIN_ROOM=!myroomid:matrix.example.org 33 | ENV KNOWN_TOKENS= 34 | 35 | # run as a non-root user, @see: https://busybox.net/downloads/BusyBox.html#adduser 36 | RUN set -x \ 37 | && adduser -D -H -g '' -u 1000 matrix \ 38 | && mkdir -p "${LOGIN_STORE_PATH}" \ 39 | && chown -R matrix. "${LOGIN_STORE_PATH}" 40 | 41 | COPY docker-entrypoint.sh ./ 42 | ENTRYPOINT [ "./docker-entrypoint.sh" ] 43 | 44 | COPY src/ ./src/ 45 | 46 | USER matrix 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Immánuel! 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # End-to-end encrypted (E2EE) Matrix Webhook Gateway 2 | 3 | A microservice that forwards form data or JSON objects received in an HTTP POST request to an end-to-end-encrypted (E2EE) Matrix room. There is no schema restriction on the posted data, so you can throw at it _anyting_! Supports multiple rooms and sender aliases with different associated webhook URLs, e.g, one for Grafana, another for `curl`, and so on. Can convert the received payload to JSON or YAML for better message readability, and can apply Markdown formatting to messages. Easy installation with `docker-compose`. 4 | 5 | ## Table of contents 6 | 7 | - [Usage](#usage) 8 | - [Configuration](#configuration) 9 | - [Available customizations of posted messages](#available-customizations-of-posted-messages) 10 | - [Matrix connection parameters](#matrix-connection-parameters) 11 | - [The notification channels](#the-notification-channels) 12 | - [Dependencies](#dependencies) 13 | - [Development](#development) 14 | - [Troubleshooting](#troubleshooting) 15 | - [Improvement ideas](#improvement-ideas) 16 | - [Disclaimer](#disclaimer) 17 | - [Contact](#contact) 18 | 19 | ## Usage 20 | 21 | - Create a new Matrix account on your homeserver for receiving webhooks 22 | - Add this account to a new E2EE room with yourself then login with the new account and accept the invite 23 | - Clone this repo, create a copy of the provided `.env.example` file as `.env`, and fill in all the required info 24 | - Start up the gateway. It logs in, sends a greeting message, and listens to webhook events 25 | - Send data with `curl` or any HTTP POST capable client to the gateway 26 | - Enjoy the messages in your chat! 27 | 28 | ```bash 29 | cp .env{.example,} 30 | # edit the .env with your preferred editor 31 | 32 | mkdir -p store 33 | chown 1000:1000 store 34 | sudo docker-compose up -d --build 35 | 36 | # post form data 37 | curl -d 'hello="world"' http://localhost:8000/post/YOURSECRETTOKEN 38 | 39 | # post a JSON 40 | curl -H 'Content-Type: application/json' -d '{"hello":"world"}' http://localhost:8000/post/YOURSECRETTOKEN 41 | 42 | # post a text file by converting it to a valid JSON array 43 | cat /etc/resolv.conf | jq -R -s -c 'split("\n")' | curl -H 'Content-Type: application/json' -d @- http://localhost:8000/post/YOURSECRETTOKEN 44 | 45 | # make posts cleaner by adding all parameters to a curl config file 46 | printf '# @see: https://ec.haxx.se/cmdline-configfile.html 47 | url = "http://localhost:8000/post/YOURSECRETTOKEN" 48 | header = "Content-Type: application/json" 49 | output = /dev/null 50 | silent 51 | ' > ~/.matrix.curlrc 52 | /any/command | curl -K ~/.matrix.curlrc -d @- 53 | ``` 54 | 55 | ## Configuration 56 | 57 | The gateway tries to join all of the specified rooms in the `.env` file on start. However, you must make sure to invite the webhook user and accept the invite on behalf of them from any client before you start up the gateway! 58 | 59 | ### Available customizations of posted messages 60 | 61 | - Message formatting via `MESSAGE_FORMAT`: `raw` | `json` | `yaml` (default) 62 | - Markdown formatting turned on or off via `USE_MARKDOWN`: `True` | `False` (default) 63 | - ASCII (e.g., `\u1234`) or Unicode characters (e.g., `ű`) in JSON or YAML content via `ALLOW_UNICODE`: `True` (default) | `False` 64 | - Include the sender app name in messages via `DISPLAY_APP_NAME`: `True` (default) | `False` 65 | 66 | ### Matrix connection parameters 67 | 68 | - The URL of the Matrix server via `MATRIX_SERVER`: a string like `https://matrix.example.org` 69 | - SSL cert checking turned on or off via `MATRIX_SSLVERIFY`: `True` (default) | `False` 70 | - The webhook user ID via `MATRIX_USERID`: a string like `@myhook:matrix.example.org` 71 | - The webhook user password via `MATRIX_PASSWORD`: a string like `mypass+*!word` 72 | - Put the string in quotes if your password contains likely sell-unsafe characters 73 | - A device name that the webhook user will use via `MATRIX_DEVICE`: a string like `docker` 74 | - The room where the webhook user will send its greeting upon (re)start via `MATRIX_ADMIN_ROOM`: a string like `!privatechatwiththebotuser:matrix.example.org` 75 | - You must use the room ID (with `!`) at all times even if the room has an alias like `#alias:matrix.example.org`! 76 | - You can invite the webhook user to a private chat with yourself to get a different room than the ones you're using for notification, it's up to you. Later, we can add some basic commands for the webhook service as it can read messages in joined rooms, so I made the admin room a private chat with the bot to be future-proof. 77 | 78 | ### The notification channels 79 | 80 | - The list of `token,roomid,name` triplets separated by spaces via `KNOWN_TOKENS`: a string like 81 | `YOURSECRETTOKEN,!myroomid:matrix.example.org,Curl anOTheRToKen99,!myroomid2:matrix.example.org,Grafana` 82 | - Put the string in quotes if you have more than one triplet ie. you have at least one space on the line 83 | - The `token` and `name` parts should match the following regexp: `[a-zA-Z0-9]+` 84 | - You must use the room ID (with `!`) at all times even if the room has an alias like `#alias:matrix.example.org`! 85 | - You can use different room IDs for different notifications, and these can be different from the admin room as well. But you can use the same room for everything, it depends on your use case. 86 | 87 | ### "Hidden" parameters that need no change most of the time 88 | 89 | - The Python log level via `PYTHON_LOG_LEVEL`: `debug` | `info` (default) | `warn` | `error` | `critical` 90 | - The store path of the saved login session via `LOGIN_STORE_PATH`: a path-string without trailing slash like `/config` (default) 91 | - If you want to make login sessions persist to avoid device IDs stacking up on the webhook user, you can put this path on a docker volume with read-write permissions to the `1000:1000` user:group. 92 | 93 | ## Dependencies 94 | 95 | - [Docker](https://www.docker.com/) 96 | - [Docker Compose](https://github.com/docker/compose) 97 | 98 | Installation on Manjaro Linux: 99 | 100 | ```bash 101 | sudo pacman -S docker docker-compose 102 | 103 | sudo systemctl enable --now docker 104 | ``` 105 | 106 | Of course, you'll also need a [Matrix server](https://matrix.org/discover/) up and running with at least one E2EE room and two users joined in the same room (the webhook user and probably _you_). Explaining setting these up is way beyond the scope of this document, so please see the online docs for proper instructions, or use a hosted Matrix server. 107 | 108 | ## Development 109 | 110 | Install global dependencies, install dev dependencies, create a new virtual environment, install package dependencies, then start the project: 111 | 112 | ```bash 113 | # Manjaro 114 | sudo pacman -S libolm 115 | sudo pip install virtualenv 116 | 117 | virtualenv venv 118 | 119 | # Fish 120 | source venv/bin/activate.fish 121 | 122 | pip install -r requirements.txt 123 | 124 | ./docker-entrypoint.sh 125 | ``` 126 | 127 | ## Troubleshooting 128 | 129 | - You might need to turn off SSL cert verification (with `False` in the environment file) if your certs are self-signed or you experience any problem with the cert verification even if you have a valid cert. 130 | - Depending on the content of your messages, you might need to experiment with the available formatting combinations that suits your use case and eyes. We are different, I like plain YAML best with Unicode characters. 131 | 132 | ## Improvement ideas 133 | 134 | - Execute predefined commands from the admin room for a set of predefined admin users. Maubot seems to be more flexible in this case but we could implement simple controls for the webhok gateway itself. 135 | 136 | ## Disclaimer 137 | 138 | **This is an experimental project. I do not take responsibility for anything regarding the use or misuse of the contents of this repository.** 139 | 140 | - Tested with Grafana webhooks and Synapse as a Matrix server, but in theory, it should work with any source capable of sending HTTP POST requests with valid form data or JSON objects (e.g., `curl`). 141 | - Tested in rooms only with E2EE enabled as the main goal of this project is to receive arbitrary data in encrypted rooms. 142 | - JSON and YAML formatting is indented by 2 spaces, no further processing is being made. The padded object's size is limited by your server's maximum message size. 143 | - If you expose this service to the net, malicious actors could only send spam notifications if they knew any of your long and random tokens, as the token acts as auth. Otherwise, the messages are not routed to a Matrix room, so you're safe. However, if you host your message source and this gateway on the same network, you can use local hostnames, DNS or IP addresses to set the webhook up in the source. This way the message gateway won't be accessible from the outside, and your message data won't leave the internal network without encryption. 144 | - If you use RocketChat, there is a similar project for that service here: [immanuelfodor/rocketchat-push-gateway](https://github.com/immanuelfodor/rocketchat-push-gateway) 145 | - If you use XMPP, there is a similar project for that service here: [immanuelfodor/xmpp-muc-message-gateway](https://github.com/immanuelfodor/xmpp-muc-message-gateway) 146 | 147 | ## Contact 148 | 149 | Immánuel Fodor 150 | [fodor.it](https://fodor.it/matrixmsgwit) | [Linkedin](https://fodor.it/matrixmsgwin) 151 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | matrix-e2ee-webhook: 5 | build: . 6 | image: matrix-e2ee-webhook:master 7 | container_name: matrix-e2ee-webhook 8 | restart: unless-stopped 9 | env_file: 10 | - .env 11 | ports: 12 | - "8000:8000" 13 | volumes: 14 | - ./store:/config 15 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # useful when running the script locally in a virtualenv, 4 | # otherwise, the OS env is already populated in the container 5 | if [ -f '.env' ] ; then 6 | echo 'Environment file found, sourcing it...' 7 | 8 | set -a 9 | . ./.env 10 | set +a 11 | 12 | export PYTHON_LOG_LEVEL=debug 13 | export LOGIN_STORE_PATH=./store 14 | fi 15 | 16 | echo 'Starting the Python app...' 17 | python src/main.py 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | termcolor 2 | markdown 3 | pyyaml 4 | typing_extensions 5 | matrix-nio[e2e] 6 | -------------------------------------------------------------------------------- /src/E2EEClient.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import sys 6 | from typing import Optional 7 | 8 | import yaml 9 | from markdown import markdown 10 | from nio import (AsyncClient, AsyncClientConfig, LoginResponse, MatrixRoom, 11 | RoomMessageText, SyncResponse) 12 | from termcolor import colored 13 | 14 | 15 | class E2EEClient: 16 | def __init__(self, join_rooms: set): 17 | self.STORE_PATH = os.environ['LOGIN_STORE_PATH'] 18 | self.CONFIG_FILE = f"{self.STORE_PATH}/credentials.json" 19 | 20 | self.join_rooms = join_rooms 21 | self.client: AsyncClient = None 22 | self.client_config = AsyncClientConfig( 23 | max_limit_exceeded=0, 24 | max_timeouts=0, 25 | store_sync_tokens=True, 26 | encryption_enabled=True, 27 | ) 28 | 29 | self.greeting_sent = False 30 | 31 | def _write_details_to_disk(self, resp: LoginResponse, homeserver) -> None: 32 | with open(self.CONFIG_FILE, "w") as f: 33 | json.dump( 34 | { 35 | 'homeserver': homeserver, # e.g. "https://matrix.example.org" 36 | 'user_id': resp.user_id, # e.g. "@user:example.org" 37 | 'device_id': resp.device_id, # device ID, 10 uppercase letters 38 | 'access_token': resp.access_token # cryptogr. access token 39 | }, 40 | f 41 | ) 42 | 43 | async def _login_first_time(self) -> None: 44 | homeserver = os.environ['MATRIX_SERVER'] 45 | user_id = os.environ['MATRIX_USERID'] 46 | pw = os.environ['MATRIX_PASSWORD'] 47 | device_name = os.environ['MATRIX_DEVICE'] 48 | 49 | if not os.path.exists(self.STORE_PATH): 50 | os.makedirs(self.STORE_PATH) 51 | 52 | self.client = AsyncClient( 53 | homeserver, 54 | user_id, 55 | store_path=self.STORE_PATH, 56 | config=self.client_config, 57 | ssl=(os.environ['MATRIX_SSLVERIFY'] == 'True'), 58 | ) 59 | 60 | resp = await self.client.login(password=pw, device_name=device_name) 61 | 62 | if (isinstance(resp, LoginResponse)): 63 | self._write_details_to_disk(resp, homeserver) 64 | else: 65 | logging.info( 66 | f"homeserver = \"{homeserver}\"; user = \"{user_id}\"") 67 | logging.critical(f"Failed to log in: {resp}") 68 | sys.exit(1) 69 | 70 | async def _login_with_stored_config(self) -> None: 71 | if self.client: 72 | return 73 | 74 | with open(self.CONFIG_FILE, "r") as f: 75 | config = json.load(f) 76 | 77 | self.client = AsyncClient( 78 | config['homeserver'], 79 | config['user_id'], 80 | device_id=config['device_id'], 81 | store_path=self.STORE_PATH, 82 | config=self.client_config, 83 | ssl=bool(os.environ['MATRIX_SSLVERIFY']), 84 | ) 85 | 86 | self.client.restore_login( 87 | user_id=config['user_id'], 88 | device_id=config['device_id'], 89 | access_token=config['access_token'] 90 | ) 91 | 92 | async def login(self) -> None: 93 | if os.path.exists(self.CONFIG_FILE): 94 | logging.info('Logging in using stored credentials.') 95 | else: 96 | logging.info('First time use, did not find credential file.') 97 | await self._login_first_time() 98 | logging.info( 99 | f"Logged in, credentials are stored under '{self.STORE_PATH}'.") 100 | 101 | await self._login_with_stored_config() 102 | 103 | async def _message_callback(self, room: MatrixRoom, event: RoomMessageText) -> None: 104 | logging.info(colored( 105 | f"@{room.user_name(event.sender)} in {room.display_name} | {event.body}", 106 | 'green' 107 | )) 108 | 109 | async def _sync_callback(self, response: SyncResponse) -> None: 110 | logging.info(f"We synced, token: {response.next_batch}") 111 | 112 | if not self.greeting_sent: 113 | self.greeting_sent = True 114 | 115 | greeting = f"Hi, I'm up and runnig from **{os.environ['MATRIX_DEVICE']}**, waiting for webhooks!" 116 | await self.send_message(greeting, os.environ['MATRIX_ADMIN_ROOM'], 'Webhook server') 117 | 118 | async def send_message( 119 | self, 120 | message: str, 121 | room: str, 122 | sender: str, 123 | sync: Optional[bool] = False 124 | ) -> None: 125 | if sync: 126 | await self.client.sync(timeout=3000, full_state=True) 127 | 128 | msg_prefix = "" 129 | if os.environ['DISPLAY_APP_NAME'] == 'True': 130 | msg_prefix = f"**{sender}** says: \n" 131 | 132 | content = { 133 | 'msgtype': 'm.text', 134 | 'body': f"{msg_prefix}{message}", 135 | } 136 | if os.environ['USE_MARKDOWN'] == 'True': 137 | # Markdown formatting removes YAML newlines if not padded with spaces, 138 | # and can also mess up posted data like system logs 139 | logging.debug('Markdown formatting is turned on.') 140 | 141 | content['format'] = 'org.matrix.custom.html' 142 | content['formatted_body'] = markdown( 143 | f"{msg_prefix}{message}", extensions=['extra']) 144 | 145 | await self.client.room_send( 146 | room_id=room, 147 | message_type="m.room.message", 148 | content=content, 149 | ignore_unverified_devices=True 150 | ) 151 | 152 | async def run(self) -> None: 153 | await self.login() 154 | 155 | self.client.add_event_callback(self._message_callback, RoomMessageText) 156 | self.client.add_response_callback(self._sync_callback, SyncResponse) 157 | 158 | if self.client.should_upload_keys: 159 | await self.client.keys_upload() 160 | 161 | for room in self.join_rooms: 162 | await self.client.join(room) 163 | await self.client.joined_rooms() 164 | 165 | logging.info('The Matrix client is waiting for events.') 166 | 167 | await self.client.sync_forever(timeout=300000, full_state=True) 168 | -------------------------------------------------------------------------------- /src/WebhookServer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | 6 | import yaml 7 | from aiohttp import web 8 | 9 | from E2EEClient import E2EEClient 10 | 11 | 12 | class WebhookServer: 13 | def __init__(self): 14 | self.matrix_client: E2EEClient = None 15 | self.WEBHOOK_PORT = int(os.environ.get('WEBHOOK_PORT', 8000)) 16 | self.KNOWN_TOKENS = self._parse_known_tokens( 17 | os.environ['KNOWN_TOKENS']) 18 | 19 | def _parse_known_tokens(self, rooms: str) -> dict: 20 | known_tokens = {} 21 | 22 | for pairs in rooms.split(' '): 23 | token, room, app_name = pairs.split(',') 24 | known_tokens[token] = {'room': room, 'app_name': app_name} 25 | 26 | return known_tokens 27 | 28 | def get_known_rooms(self) -> set: 29 | known_rooms = set() 30 | 31 | known_rooms.add(os.environ['MATRIX_ADMIN_ROOM']) 32 | for token in self.KNOWN_TOKENS: 33 | known_rooms.add(self.KNOWN_TOKENS[token]['room']) 34 | 35 | return known_rooms 36 | 37 | def _format_message(self, msg_format: str, allow_unicode: bool, data) -> str: 38 | if msg_format == 'json': 39 | return json.dumps(data, indent=2, ensure_ascii=(not allow_unicode)) 40 | if msg_format == 'yaml': 41 | return yaml.dump(data, indent=2, allow_unicode=allow_unicode) 42 | 43 | async def _get_index(self, request: web.Request) -> web.Response: 44 | return web.json_response({'success': True}) 45 | 46 | async def _post_hook(self, request: web.Request) -> web.Response: 47 | message_format = os.environ['MESSAGE_FORMAT'] 48 | allow_unicode = os.environ['ALLOW_UNICODE'] == 'True' 49 | 50 | token = request.match_info.get('token', '') 51 | logging.debug(f"Login token: {token}") 52 | logging.debug(f"Headers: {request.headers}") 53 | 54 | payload = await request.read() 55 | data = payload.decode() 56 | logging.info(f"Received raw data: {data}") 57 | 58 | if token not in self.KNOWN_TOKENS.keys(): 59 | logging.error( 60 | f"Login token '{token}' is not recognized as known token.") 61 | return web.json_response({'error': 'Token mismatch'}, status=404) 62 | 63 | if message_format not in ['raw', 'json', 'yaml']: 64 | logging.error( 65 | f"Message format '{message_format}' is not allowed, please check the config.") 66 | return web.json_response({'error': 'Gateway configured with unknown message format'}, status=415) 67 | 68 | if message_format != 'raw': 69 | data = dict(await request.post()) 70 | 71 | try: 72 | data = await request.json() 73 | except: 74 | logging.error('Error decoding data as JSON.') 75 | finally: 76 | logging.debug(f"Decoded data: {data}") 77 | 78 | data = self._format_message(message_format, allow_unicode, data) 79 | 80 | logging.debug(f"{message_format.upper()} formatted data: {data}") 81 | await self.matrix_client.send_message( 82 | data, 83 | self.KNOWN_TOKENS[token]['room'], 84 | self.KNOWN_TOKENS[token]['app_name'] 85 | ) 86 | 87 | return web.json_response({'success': True}) 88 | 89 | async def run(self, matrix_client: E2EEClient) -> None: 90 | self.matrix_client = matrix_client 91 | app = web.Application() 92 | 93 | app.router.add_get('/', self._get_index) 94 | app.router.add_post('/post/{token:[a-zA-Z0-9]+}', self._post_hook) 95 | 96 | runner = web.AppRunner(app) 97 | await runner.setup() 98 | 99 | site = web.TCPSite( 100 | runner, 101 | host='0.0.0.0', 102 | port=self.WEBHOOK_PORT 103 | ) 104 | 105 | logging.info('The web server is waiting for events.') 106 | await site.start() 107 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import sys 5 | import traceback 6 | 7 | from E2EEClient import E2EEClient 8 | from WebhookServer import WebhookServer 9 | 10 | 11 | async def main() -> None: 12 | logging.basicConfig( 13 | level=logging.getLevelName( 14 | os.environ.get('PYTHON_LOG_LEVEL', 'info').upper()), 15 | format='%(asctime)s | %(levelname)s | module:%(name)s | %(message)s' 16 | ) 17 | 18 | webhook_server = WebhookServer() 19 | matrix_client = E2EEClient(webhook_server.get_known_rooms()) 20 | processes = [matrix_client.run(), webhook_server.run(matrix_client)] 21 | 22 | await asyncio.gather(*processes, return_exceptions=True) 23 | 24 | 25 | try: 26 | asyncio.get_event_loop().run_until_complete(main()) 27 | except Exception: 28 | logging.critical(traceback.format_exc()) 29 | sys.exit(1) 30 | except KeyboardInterrupt: 31 | logging.critical('Received keyboard interrupt.') 32 | sys.exit(0) 33 | --------------------------------------------------------------------------------