diff --git a/CHANGELOG.md b/CHANGELOG.md
index 85da369d..21bb1a83 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
## New features
- [MS Power Automate] New Alert Channel with Microsoft Power Automate - [#1505](https://github.com/jertel/elastalert2/pull/1505) [#1513](https://github.com/jertel/elastalert2/pull/1513) [#1519](https://github.com/jertel/elastalert2/pull/1519) - @marssilva, @jertel
+- [Matrix Hookshot] New Alerter for sending alerts to Matrix via Hookshot - [#1525](https://github.com/jertel/elastalert2/pull/1525) - @jertel
## Other changes
- [Indexer] Fixed fields types error on instance indexer_alert_config in schema.yml - [#1499](https://github.com/jertel/elastalert2/pull/1499) - @olehpalanskyi
diff --git a/docs/source/alerts.rst b/docs/source/alerts.rst
index 8d476d11..4bac3aaf 100644
--- a/docs/source/alerts.rst
+++ b/docs/source/alerts.rst
@@ -33,6 +33,7 @@ or
- jira
- lark
- linenotify
+ - matrixhookshot
- mattermost
- ms_teams
- ms_power_automate
@@ -1424,6 +1425,33 @@ Example usage::
- "linenotify"
linenotify_access_token: "Your linenotify access token"
+Matrix Hookshot
+~~~~~~~~~~~~~~~
+
+The Matrix Hookshot alerter will send a notification to a Hookshot server that's already setup within the Matrix server. The body of the notification is formatted the same as with other alerters.
+
+See the Hookshot Webhook documentation for more information: https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html#webhook-handling
+
+The alerter requires the following option:
+
+``matrixhookshot_webhook_url``: The webhook URL that was provided to you by the hookshot bot. Ex: https://XXXXX.com/webhook/6de1f483-5c4b-4bb8-784a-f09129f45225. You can also use a list of URLs to send to multiple webhooks.
+
+Optional:
+
+``matrixhookshot_username``: Optional username to prepend to the text body.
+
+``matrixhookshot_text``: Override the default alert text with custom text formatting.
+
+``matrixhookshot_html``: Specify HTML alert content to use instead of the default alert text.
+
+``matrixhookshot_proxy``: By default ElastAlert 2 will not use a network proxy to send notifications to Hookshot. Set this option using ``hostname:port`` if you need to use a proxy. only supports https.
+
+``matrixhookshot_ignore_ssl_errors``: By default ElastAlert 2 will verify SSL certificate. Set this option to ``True`` if you want to ignore SSL errors.
+
+``matrixhookshot_timeout``: You can specify a timeout value, in seconds, for making communicating with Hookshot. The default is 10. If a timeout occurs, the alert will be retried next time ElastAlert 2 cycles.
+
+``matrixhookshot_ca_certs``: Set this option to ``True`` or a path to a CA cert bundle or directory (eg: ``/etc/ssl/certs/ca-certificates.crt``) to validate the SSL certificate.
+
Mattermost
~~~~~~~~~~
diff --git a/docs/source/elastalert.rst b/docs/source/elastalert.rst
index da49c6fd..7df839d2 100755
--- a/docs/source/elastalert.rst
+++ b/docs/source/elastalert.rst
@@ -49,6 +49,7 @@ Currently, we have support built in for these alert types:
- Jira
- Lark
- Line Notify
+- Matrix Hookshot
- Mattermost
- Microsoft Teams
- Microsoft Power Automate
diff --git a/elastalert/alerters/matrixhookshot.py b/elastalert/alerters/matrixhookshot.py
new file mode 100644
index 00000000..dc4da224
--- /dev/null
+++ b/elastalert/alerters/matrixhookshot.py
@@ -0,0 +1,81 @@
+import copy
+import json
+import requests
+import warnings
+
+from elastalert.alerts import Alerter, DateTimeEncoder
+from elastalert.util import elastalert_logger, EAException, lookup_es_key
+from requests.exceptions import RequestException
+
+
+class MatrixHookshotAlerter(Alerter):
+ """ Creates a Matrix Hookshot room message for each alert """
+ required_options = frozenset(['matrixhookshot_webhook_url'])
+
+ def __init__(self, rule):
+ super(MatrixHookshotAlerter, self).__init__(rule)
+ self.matrixhookshot_webhook_url = self.rule.get('matrixhookshot_webhook_url', None)
+ if isinstance(self.matrixhookshot_webhook_url, str):
+ self.matrixhookshot_webhook_url = [self.matrixhookshot_webhook_url]
+ self.matrixhookshot_proxy = self.rule.get('matrixhookshot_proxy', None)
+ self.matrixhookshot_username = self.rule.get('matrixhookshot_username', '')
+ self.matrixhookshot_text = self.rule.get('matrixhookshot_text', '')
+ self.matrixhookshot_html = self.rule.get('matrixhookshot_html', '')
+ self.matrixhookshot_ignore_ssl_errors = self.rule.get('matrixhookshot_ignore_ssl_errors', False)
+ self.matrixhookshot_timeout = self.rule.get('matrixhookshot_timeout', 10)
+ self.matrixhookshot_ca_certs = self.rule.get('matrixhookshot_ca_certs')
+
+ def format_body(self, body):
+ # https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html
+ return body
+
+ def get_aggregation_summary_text__maximum_width(self):
+ width = super(MatrixHookshotAlerter, self).get_aggregation_summary_text__maximum_width()
+ # Reduced maximum width for prettier MatrixHookshot display.
+ return min(width, 75)
+
+ def get_aggregation_summary_text(self, matches):
+ text = super(MatrixHookshotAlerter, self).get_aggregation_summary_text(matches)
+ if text:
+ text = '```\n{0}```\n'.format(text)
+ return text
+
+ def alert(self, matches):
+ body = self.create_alert_body(matches)
+
+ body = self.format_body(body)
+ # post to matrixhookshot
+ headers = {'content-type': 'application/json'}
+ # set https proxy, if it was provided
+ proxies = {'https': self.matrixhookshot_proxy} if self.matrixhookshot_proxy else None
+ payload = {
+ 'text': body
+ }
+ if self.matrixhookshot_username:
+ payload['username'] = self.matrixhookshot_username
+ if self.matrixhookshot_html:
+ payload['html'] = self.matrixhookshot_html
+ if self.matrixhookshot_text:
+ payload['text'] = self.matrixhookshot_text
+
+ for url in self.matrixhookshot_webhook_url:
+ try:
+ if self.matrixhookshot_ca_certs:
+ verify = self.matrixhookshot_ca_certs
+ else:
+ verify = not self.matrixhookshot_ignore_ssl_errors
+ if self.matrixhookshot_ignore_ssl_errors:
+ requests.packages.urllib3.disable_warnings()
+ response = requests.post(
+ url, data=json.dumps(payload, cls=DateTimeEncoder),
+ headers=headers, verify=verify,
+ proxies=proxies,
+ timeout=self.matrixhookshot_timeout)
+ warnings.resetwarnings()
+ response.raise_for_status()
+ except RequestException as e:
+ raise EAException("Error posting to matrixhookshot: %s" % e)
+ elastalert_logger.info("Alert '%s' sent to MatrixHookshot" % self.rule['name'])
+
+ def get_info(self):
+ return {'type': 'matrixhookshot' }
diff --git a/elastalert/loaders.py b/elastalert/loaders.py
index ac377960..7075dd5a 100644
--- a/elastalert/loaders.py
+++ b/elastalert/loaders.py
@@ -44,6 +44,7 @@
from elastalert.alerters.alertmanager import AlertmanagerAlerter
from elastalert.alerters.email import EmailAlerter
from elastalert.alerters.jira import JiraAlerter
+from elastalert.alerters.matrixhookshot import MatrixHookshotAlerter
from elastalert.alerters.mattermost import MattermostAlerter
from elastalert.alerters.opsgenie import OpsGenieAlerter
from elastalert.alerters.pagerduty import PagerDutyAlerter
@@ -141,6 +142,7 @@ class RulesLoader(object):
'gelf': elastalert.alerters.gelf.GelfAlerter,
'iris': elastalert.alerters.iris.IrisAlerter,
'indexer': IndexerAlerter,
+ 'matrixhookshot': MatrixHookshotAlerter,
}
# A partial ordering of alert types. Relative order will be preserved in the resulting alerts list
diff --git a/elastalert/schema.yaml b/elastalert/schema.yaml
index b0e94818..aee33262 100644
--- a/elastalert/schema.yaml
+++ b/elastalert/schema.yaml
@@ -642,6 +642,16 @@ properties:
### Line Notify
linenotify_access_token: {type: string}
+ ### Matrix Hookshot
+ matrixhookshot_ca_certs: {type: [boolean, string]}
+ matrixhookshot_webhook_url: *arrayOfString
+ matrixhookshot_username: {type: string}
+ matrixhookshot_html: {type: string}
+ matrixhookshot_text: {type: string}
+ matrixhookshot_proxy: {type: string}
+ matrixhookshot_ignore_ssl_errors: {type: boolean}
+ matrixhookshot_timeout: {type: integer}
+
### Mattermost
mattermost_webhook_url: *arrayOfString
mattermost_proxy: {type: string}
diff --git a/tests/alerters/matrixhookshot_test.py b/tests/alerters/matrixhookshot_test.py
new file mode 100644
index 00000000..a0c58b83
--- /dev/null
+++ b/tests/alerters/matrixhookshot_test.py
@@ -0,0 +1,334 @@
+import json
+import logging
+import pytest
+
+from unittest import mock
+
+from requests import RequestException
+
+from elastalert.alerters.matrixhookshot import MatrixHookshotAlerter
+from elastalert.loaders import FileRulesLoader
+from elastalert.util import EAException
+
+
+def test_matrixhookshot_uses_custom_title(caplog):
+ caplog.set_level(logging.INFO)
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'alert': []
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ match = {
+ '@timestamp': '2017-01-01T00:00:00',
+ 'somefield': 'foobarbaz'
+ }
+ with mock.patch('requests.post') as mock_post_request:
+ alert.alert([match])
+
+ expected_data = {
+ 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n',
+ }
+ mock_post_request.assert_called_once_with(
+ rule['matrixhookshot_webhook_url'],
+ data=mock.ANY,
+ headers={'content-type': 'application/json'},
+ proxies=None,
+ verify=True,
+ timeout=10
+ )
+ assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data'])
+ assert ('elastalert', logging.INFO, "Alert 'Test Rule' sent to MatrixHookshot") == caplog.record_tuples[0]
+
+
+def test_matrixhookshot_uses_custom_timeout():
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'alert': [],
+ 'matrixhookshot_timeout': 20
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ match = {
+ '@timestamp': '2017-01-01T00:00:00',
+ 'somefield': 'foobarbaz'
+ }
+ with mock.patch('requests.post') as mock_post_request:
+ alert.alert([match])
+
+ expected_data = {
+ 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n'
+ }
+ mock_post_request.assert_called_once_with(
+ rule['matrixhookshot_webhook_url'],
+ data=mock.ANY,
+ headers={'content-type': 'application/json'},
+ proxies=None,
+ verify=True,
+ timeout=20
+ )
+ assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data'])
+
+
+def test_matrixhookshot_uses_rule_name_when_custom_title_is_not_provided():
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': ['http://please.dontgohere.matrixhookshot'],
+ 'alert': []
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ match = {
+ '@timestamp': '2017-01-01T00:00:00',
+ 'somefield': 'foobarbaz'
+ }
+ with mock.patch('requests.post') as mock_post_request:
+ alert.alert([match])
+
+ expected_data = {
+ 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n',
+ }
+ mock_post_request.assert_called_once_with(
+ rule['matrixhookshot_webhook_url'][0],
+ data=mock.ANY,
+ headers={'content-type': 'application/json'},
+ proxies=None,
+ verify=True,
+ timeout=10
+ )
+ assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data'])
+
+
+def test_matrixhookshot_proxy():
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'matrixhookshot_proxy': 'http://proxy.url',
+ 'alert': []
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ match = {
+ '@timestamp': '2017-01-01T00:00:00',
+ 'somefield': 'foobarbaz'
+ }
+ with mock.patch('requests.post') as mock_post_request:
+ alert.alert([match])
+
+ expected_data = {
+ 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n',
+ }
+ mock_post_request.assert_called_once_with(
+ rule['matrixhookshot_webhook_url'],
+ data=mock.ANY,
+ headers={'content-type': 'application/json'},
+ proxies={'https': rule['matrixhookshot_proxy']},
+ verify=True,
+ timeout=10
+ )
+ assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data'])
+
+
+def test_matrixhookshot_username():
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'matrixhookshot_username': 'test elastalert',
+ 'alert': []
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ match = {
+ '@timestamp': '2017-01-01T00:00:00',
+ 'somefield': 'foobarbaz'
+ }
+ with mock.patch('requests.post') as mock_post_request:
+ alert.alert([match])
+
+ expected_data = {
+ 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n',
+ 'username': 'test elastalert',
+ }
+ mock_post_request.assert_called_once_with(
+ rule['matrixhookshot_webhook_url'],
+ data=mock.ANY,
+ headers={'content-type': 'application/json'},
+ proxies=None,
+ verify=True,
+ timeout=10
+ )
+ assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data'])
+
+
+def test_matrixhookshot_text_html():
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'matrixhookshot_text': 'Hello',
+ 'matrixhookshot_html': 'Hello',
+ 'alert': []
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ match = {
+ '@timestamp': '2017-01-01T00:00:00',
+ 'somefield': 'foobarbaz'
+ }
+ with mock.patch('requests.post') as mock_post_request:
+ alert.alert([match])
+
+ expected_data = {
+ 'text': 'Hello',
+ 'html': 'Hello',
+ }
+ mock_post_request.assert_called_once_with(
+ rule['matrixhookshot_webhook_url'],
+ data=mock.ANY,
+ headers={'content-type': 'application/json'},
+ proxies=None,
+ verify=True,
+ timeout=10
+ )
+ assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data'])
+
+
+@pytest.mark.parametrize('ca_certs, ignore_ssl_errors, expect_verify', [
+ ('', '', True),
+ ('', True, False),
+ ('', False, True),
+ (True, '', True),
+ (True, True, True),
+ (True, False, True),
+ (False, '', True),
+ (False, True, False),
+ (False, False, True)
+])
+def test_matrixhookshot_ca_certs(ca_certs, ignore_ssl_errors, expect_verify):
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'alert': []
+ }
+ if ca_certs:
+ rule['matrixhookshot_ca_certs'] = ca_certs
+
+ if ignore_ssl_errors:
+ rule['matrixhookshot_ignore_ssl_errors'] = ignore_ssl_errors
+
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ match = {
+ '@timestamp': '2017-01-01T00:00:00',
+ 'somefield': 'foobarbaz'
+ }
+ with mock.patch('requests.post') as mock_post_request:
+ alert.alert([match])
+
+ expected_data = {
+ 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n',
+ }
+ mock_post_request.assert_called_once_with(
+ rule['matrixhookshot_webhook_url'],
+ data=mock.ANY,
+ headers={'content-type': 'application/json'},
+ proxies=None,
+ verify=expect_verify,
+ timeout=10
+ )
+ assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data'])
+
+
+def test_matrixhookshot_ea_exception():
+ with pytest.raises(EAException) as ea:
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'alert': []
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ match = {
+ '@timestamp': '2017-01-01T00:00:00',
+ 'somefield': 'foobarbaz'
+ }
+ mock_run = mock.MagicMock(side_effect=RequestException)
+ with mock.patch('requests.post', mock_run), pytest.raises(RequestException):
+ alert.alert([match])
+ assert 'Error posting to matrixhookshot: ' in str(ea)
+
+
+def test_matrixhookshot_get_aggregation_summary_text__maximum_width():
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'alert': []
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+ assert 75 == alert.get_aggregation_summary_text__maximum_width()
+
+
+def test_matrixhookshot_getinfo():
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot',
+ 'alert': []
+ }
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+
+ expected_data = {
+ 'type': 'matrixhookshot'
+ }
+ actual_data = alert.get_info()
+ assert expected_data == actual_data
+
+
+@pytest.mark.parametrize('matrixhookshot_webhook_url, expected_data', [
+ ('', 'Missing required option(s): matrixhookshot_webhook_url'),
+ ('http://please.dontgohere.matrixhookshot',
+ {
+ 'type': 'matrixhookshot',
+ }),
+])
+def test_matrixhookshot_required_error(matrixhookshot_webhook_url, expected_data):
+ try:
+ rule = {
+ 'name': 'Test Rule',
+ 'type': 'any',
+ 'alert': []
+ }
+
+ if matrixhookshot_webhook_url:
+ rule['matrixhookshot_webhook_url'] = matrixhookshot_webhook_url
+
+ rules_loader = FileRulesLoader({})
+ rules_loader.load_modules(rule)
+ alert = MatrixHookshotAlerter(rule)
+
+ actual_data = alert.get_info()
+ assert expected_data == actual_data
+ except Exception as ea:
+ assert expected_data in str(ea)