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/ Execute a local shell command when a check goes up or down.Shell Command
+
{{ ch.cmd_down }}+ {% endif %} + + {% if ch.cmd_up %} +
Execute on "up" events:
+{{ ch.cmd_up }}+ {% endif %} + {% endif %}