diff --git a/CHANGELOG.md b/CHANGELOG.md index d67e5c30..72785195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. - In monthly reports, no downtime stats for the current month (month has just started) - Add Microsoft Teams integration (#135) - Add Profile.last_active_date field for more accurate inactive user detection +- Add "Shell Commands" integration (#302) ### Bug Fixes - On mobile, "My Checks" page, always show the gear (Details) button (#286) diff --git a/README.md b/README.md index db593b6f..325f13be 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Configurations settings loaded from environment variables: | MATRIX_USER_ID | `None` | MATRIX_ACCESS_TOKEN | `None` | APPRISE_ENABLED | `"False"` +| SHELL_ENABLED | `"False"` Some useful settings keys to override are: @@ -361,6 +362,17 @@ pip install apprise ``` * enable the apprise functionality by setting the `APPRISE_ENABLED` environment variable. +### Shell Commands + +The "Shell Commands" integration runs user-defined local shell commands when checks +go up or down. This integration is disabled by default, and can be enabled by setting +the `SHELL_ENABLED` environment variable to `True`. + +Note: be careful when using "Shell Commands" integration, and only enable it when +you fully trust the users of your Healthchecks instance. The commands will be executed +by the `manage.py sendalerts` process, and will run with the same system permissions as +the `sendalerts` process. + ## Running in Production Here is a non-exhaustive list of pointers and things to check before launching a Healthchecks instance diff --git a/hc/api/models.py b/hc/api/models.py index 59c50308..04893b03 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -46,6 +46,7 @@ CHANNEL_KINDS = ( ("apprise", "Apprise"), ("mattermost", "Mattermost"), ("msteams", "Microsoft Teams"), + ("shell", "Shell Command"), ) PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"} @@ -413,6 +414,8 @@ class Channel(models.Model): return transports.Apprise(self) elif self.kind == "msteams": return transports.MsTeams(self) + elif self.kind == "shell": + return transports.Shell(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -438,6 +441,10 @@ class Channel(models.Model): def icon_path(self): return "img/integrations/%s.png" % self.kind + @property + def json(self): + return json.loads(self.value) + @property def po_priority(self): assert self.kind == "po" @@ -502,6 +509,16 @@ class Channel(models.Model): def url_up(self): return self.up_webhook_spec["url"] + @property + def cmd_down(self): + assert self.kind == "shell" + return self.json["cmd_down"] + + @property + def cmd_up(self): + assert self.kind == "shell" + return self.json["cmd_up"] + @property def slack_team(self): assert self.kind == "slack" @@ -586,13 +603,6 @@ class Channel(models.Model): return doc["value"] return self.value - @property - def sms_label(self): - assert self.kind == "sms" - if self.value.startswith("{"): - doc = json.loads(self.value) - return doc["label"] - @property def trello_token(self): assert self.kind == "trello" @@ -620,8 +630,7 @@ class Channel(models.Model): if not self.value.startswith("{"): return self.value - doc = json.loads(self.value) - return doc.get("value") + return self.json["value"] @property def email_notify_up(self): diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index 29ee09f9..024eb145 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -73,19 +73,6 @@ class NotifyTestCase(BaseTestCase): n = Notification.objects.get() self.assertEqual(n.error, "Received status code 500") - @patch("hc.api.transports.requests.request") - def test_webhooks_support_tags(self, mock_get): - template = "http://host/$TAGS" - self._setup_data("webhook", template) - self.check.tags = "foo bar" - self.check.save() - - self.channel.notify(self.check) - - args, kwargs = mock_get.call_args - self.assertEqual(args[0], "get") - self.assertEqual(args[1], "http://host/foo%20bar") - @patch("hc.api.transports.requests.request") def test_webhooks_support_variables(self, mock_get): template = "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME" @@ -711,3 +698,50 @@ class NotifyTestCase(BaseTestCase): args, kwargs = mock_post.call_args payload = kwargs["json"] self.assertEqual(payload["@type"], "MessageCard") + + @patch("hc.api.transports.os.system") + @override_settings(SHELL_ENABLED=True) + def test_shell(self, mock_system): + definition = {"cmd_down": "logger hello", "cmd_up": ""} + self._setup_data("shell", json.dumps(definition)) + mock_system.return_value = 0 + + self.channel.notify(self.check) + mock_system.assert_called_with("logger hello") + + @patch("hc.api.transports.os.system") + @override_settings(SHELL_ENABLED=True) + def test_shell_handles_nonzero_exit_code(self, mock_system): + definition = {"cmd_down": "logger hello", "cmd_up": ""} + self._setup_data("shell", json.dumps(definition)) + mock_system.return_value = 123 + + self.channel.notify(self.check) + n = Notification.objects.get() + self.assertEqual(n.error, "Command returned exit code 123") + + @patch("hc.api.transports.os.system") + @override_settings(SHELL_ENABLED=True) + def test_shell_supports_variables(self, mock_system): + definition = {"cmd_down": "logger $NAME is $STATUS ($TAG1)", "cmd_up": ""} + self._setup_data("shell", json.dumps(definition)) + mock_system.return_value = 0 + + self.check.name = "Database" + self.check.tags = "foo bar" + self.check.save() + self.channel.notify(self.check) + + mock_system.assert_called_with("logger Database is down (foo)") + + @patch("hc.api.transports.os.system") + @override_settings(SHELL_ENABLED=False) + def test_shell_disabled(self, mock_system): + definition = {"cmd_down": "logger hello", "cmd_up": ""} + self._setup_data("shell", json.dumps(definition)) + + self.channel.notify(self.check) + self.assertFalse(mock_system.called) + + n = Notification.objects.get() + self.assertEqual(n.error, "Shell commands are not enabled") diff --git a/hc/api/transports.py b/hc/api/transports.py index feac54ff..f849c460 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -1,3 +1,5 @@ +import os + from django.conf import settings from django.template.loader import render_to_string from django.utils import timezone @@ -7,6 +9,7 @@ from urllib.parse import quote, urlencode from hc.accounts.models import Profile from hc.lib import emails +from hc.lib.string import replace try: import apprise @@ -90,6 +93,48 @@ class Email(Transport): return not self.channel.email_notify_up +class Shell(Transport): + def prepare(self, template, check): + """ Replace placeholders with actual values. """ + + ctx = { + "$CODE": str(check.code), + "$STATUS": check.status, + "$NOW": timezone.now().replace(microsecond=0).isoformat(), + "$NAME": check.name, + "$TAGS": check.tags, + } + + for i, tag in enumerate(check.tags_list()): + ctx["$TAG%d" % (i + 1)] = tag + + return replace(template, ctx) + + def is_noop(self, check): + if check.status == "down" and not self.channel.cmd_down: + return True + + if check.status == "up" and not self.channel.cmd_up: + return True + + return False + + def notify(self, check): + if not settings.SHELL_ENABLED: + return "Shell commands are not enabled" + + if check.status == "up": + cmd = self.channel.cmd_up + elif check.status == "down": + cmd = self.channel.cmd_down + + cmd = self.prepare(cmd, check) + code = os.system(cmd) + + if code != 0: + return "Command returned exit code %d" % code + + class HttpTransport(Transport): @classmethod def _request(cls, method, url, **kwargs): @@ -479,7 +524,7 @@ class Apprise(HttpTransport): if not settings.APPRISE_ENABLED: # Not supported and/or enabled - return "Apprise is disabled and/or not installed." + return "Apprise is disabled and/or not installed" a = apprise.Apprise() title = tmpl("apprise_title.html", check=check) diff --git a/hc/front/forms.py b/hc/front/forms.py index ff90497a..d80b74f2 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -125,6 +125,16 @@ class AddWebhookForm(forms.Form): return json.dumps(dict(self.cleaned_data), sort_keys=True) +class AddShellForm(forms.Form): + error_css_class = "has-error" + + cmd_down = forms.CharField(max_length=1000, required=False) + cmd_up = forms.CharField(max_length=1000, required=False) + + def get_value(self): + return json.dumps(dict(self.cleaned_data), sort_keys=True) + + phone_validator = RegexValidator( regex="^\+\d{5,15}$", message="Invalid phone number format." ) diff --git a/hc/front/tests/test_add_shell.py b/hc/front/tests/test_add_shell.py new file mode 100644 index 00000000..01cd70b2 --- /dev/null +++ b/hc/front/tests/test_add_shell.py @@ -0,0 +1,53 @@ +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase + + +@override_settings(SHELL_ENABLED=True) +class AddShellTestCase(BaseTestCase): + url = "/integrations/add_shell/" + + @override_settings(SHELL_ENABLED=False) + def test_it_is_disabled_by_default(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 404) + + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "Executes a local shell command") + + def test_it_adds_two_commands_and_redirects(self): + form = {"cmd_down": "logger down", "cmd_up": "logger up"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.project, self.project) + self.assertEqual(c.cmd_down, "logger down") + self.assertEqual(c.cmd_up, "logger up") + + def test_it_adds_webhook_using_team_access(self): + form = {"cmd_down": "logger down", "cmd_up": "logger up"} + + # Logging in as bob, not alice. Bob has team access so this + # should work. + self.client.login(username="bob@example.org", password="password") + self.client.post(self.url, form) + + c = Channel.objects.get() + self.assertEqual(c.project, self.project) + self.assertEqual(c.cmd_down, "logger down") + + def test_it_handles_empty_down_command(self): + form = {"cmd_down": "", "cmd_up": "logger up"} + + self.client.login(username="alice@example.org", password="password") + self.client.post(self.url, form) + + c = Channel.objects.get() + self.assertEqual(c.cmd_down, "") + self.assertEqual(c.cmd_up, "logger up") diff --git a/hc/front/tests/test_channels.py b/hc/front/tests/test_channels.py index ba68545f..5f0dd7a3 100644 --- a/hc/front/tests/test_channels.py +++ b/hc/front/tests/test_channels.py @@ -96,9 +96,9 @@ class ChannelsTestCase(BaseTestCase): self.assertEqual(r.status_code, 200) self.assertContains(r, "(up only)") - def test_it_shows_sms_label(self): + def test_it_shows_sms_number(self): ch = Channel(kind="sms", project=self.project) - ch.value = json.dumps({"value": "+123", "label": "My Phone"}) + ch.value = json.dumps({"value": "+123"}) ch.save() self.client.login(username="alice@example.org", password="password") diff --git a/hc/front/urls.py b/hc/front/urls.py index 8b34888a..9d31d2e2 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -26,6 +26,7 @@ channel_urls = [ path("", views.channels, name="hc-channels"), path("add_email/", views.add_email, name="hc-add-email"), path("add_webhook/", views.add_webhook, name="hc-add-webhook"), + path("add_shell/", views.add_shell, name="hc-add-shell"), path("add_pd/", views.add_pd, name="hc-add-pd"), path("add_pd//", views.add_pd, name="hc-add-pd-state"), path("add_pagertree/", views.add_pagertree, name="hc-add-pagertree"), diff --git a/hc/front/views.py b/hc/front/views.py index 98e9e499..4c95b606 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -46,6 +46,7 @@ from hc.front.forms import ( EmailSettingsForm, AddMatrixForm, AddAppriseForm, + AddShellForm, ) from hc.front.schemas import telegram_callback from hc.front.templatetags.hc_extras import num_down_title, down_title, sortchecks @@ -651,6 +652,7 @@ def channels(request): "enable_trello": settings.TRELLO_APP_KEY is not None, "enable_matrix": settings.MATRIX_ACCESS_TOKEN is not None, "enable_apprise": settings.APPRISE_ENABLED is True, + "enable_shell": settings.SHELL_ENABLED is True, "use_payments": settings.USE_PAYMENTS, } @@ -816,6 +818,32 @@ def add_webhook(request): return render(request, "integrations/add_webhook.html", ctx) +@login_required +def add_shell(request): + if not settings.SHELL_ENABLED: + raise Http404("shell integration is not available") + + if request.method == "POST": + form = AddShellForm(request.POST) + if form.is_valid(): + channel = Channel(project=request.project, kind="shell") + channel.value = form.get_value() + channel.save() + + channel.assign_all_checks() + return redirect("hc-channels") + else: + form = AddShellForm() + + ctx = { + "page": "channels", + "project": request.project, + "form": form, + "now": timezone.now().replace(microsecond=0).isoformat(), + } + return render(request, "integrations/add_shell.html", ctx) + + def _prepare_state(request, session_key): state = get_random_string() request.session[session_key] = state diff --git a/hc/lib/tests/test_string.py b/hc/lib/tests/test_string.py new file mode 100644 index 00000000..5ef0dd6d --- /dev/null +++ b/hc/lib/tests/test_string.py @@ -0,0 +1,21 @@ +from django.test import TestCase + +from hc.lib.string import replace + + +class StringTestCase(TestCase): + def test_it_works(self): + result = replace("$A is $B", {"$A": "aaa", "$B": "bbb"}) + self.assertEqual(result, "aaa is bbb") + + def test_it_ignores_placeholders_in_values(self): + result = replace("$A is $B", {"$A": "$B", "$B": "$A"}) + self.assertEqual(result, "$B is $A") + + def test_it_ignores_overlapping_placeholders(self): + result = replace("$$AB", {"$A": "", "$B": "text"}) + self.assertEqual(result, "$B") + + def test_it_preserves_non_placeholder_dollar_signs(self): + result = replace("$3.50", {"$A": "text"}) + self.assertEqual(result, "$3.50") diff --git a/hc/settings.py b/hc/settings.py index 6fcc5c99..b3ea6ad5 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -207,6 +207,9 @@ MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN") # Apprise APPRISE_ENABLED = envbool("APPRISE_ENABLED", "False") +# Local shell commands +SHELL_ENABLED = envbool("SHELL_ENABLED", "False") + if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * diff --git a/static/css/icomoon.css b/static/css/icomoon.css index d1e78513..46a4c68e 100644 --- a/static/css/icomoon.css +++ b/static/css/icomoon.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?tg9zp8'); - src: url('../fonts/icomoon.eot?tg9zp8#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?tg9zp8') format('truetype'), - url('../fonts/icomoon.woff?tg9zp8') format('woff'), - url('../fonts/icomoon.svg?tg9zp8#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?r6898m'); + src: url('../fonts/icomoon.eot?r6898m#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?r6898m') format('truetype'), + url('../fonts/icomoon.woff?r6898m') format('woff'), + url('../fonts/icomoon.svg?r6898m#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,9 @@ -moz-osx-font-smoothing: grayscale; } +.icon-shell:before { + content: "\e917"; +} .icon-msteams:before { content: "\e916"; color: #4e56be; diff --git a/static/fonts/icomoon.eot b/static/fonts/icomoon.eot index 9e6da78e..ca7d10ad 100644 Binary files a/static/fonts/icomoon.eot and b/static/fonts/icomoon.eot differ diff --git a/static/fonts/icomoon.svg b/static/fonts/icomoon.svg index 51782c0e..8fc3b28f 100644 --- a/static/fonts/icomoon.svg +++ b/static/fonts/icomoon.svg @@ -41,4 +41,5 @@ + \ No newline at end of file diff --git a/static/fonts/icomoon.ttf b/static/fonts/icomoon.ttf index c82d8390..ead50d34 100644 Binary files a/static/fonts/icomoon.ttf and b/static/fonts/icomoon.ttf differ diff --git a/static/fonts/icomoon.woff b/static/fonts/icomoon.woff index ea44191b..c9dcc404 100644 Binary files a/static/fonts/icomoon.woff and b/static/fonts/icomoon.woff differ diff --git a/static/img/integrations/shell.png b/static/img/integrations/shell.png new file mode 100644 index 00000000..836f980d Binary files /dev/null and b/static/img/integrations/shell.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index ca130b0e..36406da5 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -105,7 +105,7 @@ {% elif ch.kind == "msteams" %} Microsoft Teams {% else %} - {{ ch.kind }} + {{ ch.get_kind_display }} {% endif %} @@ -329,6 +329,18 @@ {% endif %} + {% if enable_shell %} +
  • + Shell icon + +

    Shell Command

    +

    Execute a local shell command when a check goes up or down.

    + + Add Integration +
  • + {% endif %} + {% if enable_sms %}
  • Execute on "down" events:

    +
    {{ ch.cmd_down }}
    + {% endif %} + + {% if ch.cmd_up %} +

    Execute on "up" events:

    +
    {{ ch.cmd_up }}
    + {% endif %} + {% endif %}