From 8d81ea8f9d641648af4eba2586fe15ea547356e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?=
Date: Wed, 20 Nov 2019 16:00:53 +0200
Subject: [PATCH] Add "Shell Commands" integration. Fixes #302
---
CHANGELOG.md | 1 +
README.md | 12 +++
hc/api/models.py | 27 ++++--
hc/api/tests/test_notify.py | 60 +++++++++---
hc/api/transports.py | 47 ++++++++-
hc/front/forms.py | 10 ++
hc/front/tests/test_add_shell.py | 53 +++++++++++
hc/front/tests/test_channels.py | 4 +-
hc/front/urls.py | 1 +
hc/front/views.py | 28 ++++++
hc/lib/tests/test_string.py | 21 ++++
hc/settings.py | 3 +
static/css/icomoon.css | 13 ++-
static/fonts/icomoon.eot | Bin 11940 -> 12420 bytes
static/fonts/icomoon.svg | 1 +
static/fonts/icomoon.ttf | Bin 11776 -> 12256 bytes
static/fonts/icomoon.woff | Bin 11852 -> 12332 bytes
static/img/integrations/shell.png | Bin 0 -> 1870 bytes
templates/front/channels.html | 27 +++++-
templates/front/event_summary.html | 5 +
templates/integrations/add_shell.html | 121 ++++++++++++++++++++++++
templates/integrations/add_webhook.html | 2 +-
22 files changed, 404 insertions(+), 32 deletions(-)
create mode 100644 hc/front/tests/test_add_shell.py
create mode 100644 hc/lib/tests/test_string.py
create mode 100644 static/img/integrations/shell.png
create mode 100644 templates/integrations/add_shell.html
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 9e6da78e1f8182031e3b5d2e125fb48c660be596..ca7d10ade8fac9576f8edd6202978f5397d28cdd 100644
GIT binary patch
delta 749
zcmYjOO=}ZD7=CAWc4lYxJIU@EX+zRzQfb;s^U*4zRp>=p=pQH=(n2Z51}s>-m_wsI2S!|*Dr
z(2cEyV0HYuQv!e;+?Q+hYU9S!)i8sWH+{26FC*m=@
z<7Q*=+7hTZf#G&E2UR6sTeIXG30s?FhJ@RSa{ojV8GCICY8za51Wy3sea!a~K`}E@
zDSM?{HWL>86bTQ?&z5reA(HK5+$P=f6T#v5|He=J<8VJ)9wI30R=K#ZX-3Y)k;>><
zQtlWZQVyGPDVpwAnPmJ-eQulFxcxjmNU3t?-TRufVl^@PK@)F@Lri&M?g
z^@JHq+fGtSmuU={j*y{cr<&fF(`(yF!-z4?YAU6IF`Mg-9`BVxDvHWAit@M-OFLb8
zq(-?2Ei2`ckX-olN^M6kIe68JDMc{u88T_x=~$393`0>BPN`(f;@s4jq?({q*O=YZ
zxhq9rnY|vyFfoRsF-|5X2**pLOgheSAJgfWj$<65i_qp4V@B74qNDB#8JK3ub9zk6
zmmLRX^7r#8{Otrcu3%wnCGynhq
delta 266
zcmZokToTK+M2~@iL2n|P8H;|)_q!7v%K43e0(*csAvrg(plad7V{-MLw!S|Y7#I&Q
zFld@&q$Z|_`&yPTFlc=Nsxr#}3UF|j3#G8M!4Ded-+iKz;y_KO-kUIdPgs
zHxH2i0?7BvO{^$juw?wez@TFRKX@W?*2N?8qd%`M%y#MgTDsL-zmx
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 c82d839063379f249b4cdebf7f58ff37ec0d3749..ead50d347e27606b26ad4d1c6fee6b78af9c2cbb 100644
GIT binary patch
delta 760
zcmYjPIcyVA5dDAm@4xo=ySCRGBge91h=ZI1`A7mnAt0qeL|CFF1j~d(C?=K?p+MtQ
zh?baB3gW0h6clMvAY>s5O4?9SK?_1O+_QEP7|rgVH#2YkJngQX{V>}^7$Ib$HKe0M
zr_YQORD_lw^Aeu(wR*L2?a7sA2;n5)jrr{muknWcLpBag2Y=$RO;2$20DU2
zBQ)FtIDNTVpSyoj`~W--mt1cwEw8|$U?c6Yj&%L&&Gs=Itm7FRY@Wk+4>qCvr%jOF
zhCLsk#|TCHsPDz%#Y|ze?3MbnnV{&WaIousw$z^+#@Rl~9Ncw37T+uXANa9<`^Mqnd8iZ;cW5lpIk6DqlqY_fO)S%F*gbZva)%34XSM9mW-p
z_DUf&O=kuHden@hRhJ&Jfi42uPPsV1Cw{-u+uwydlWGI6&JT-+GTq0#csRKT!V1ntzX=hN`b!`~Gwe(CAMS#|UqGD!z-fB$ql47PO$T0QwO
ZoNHC`_cVf9AM+LN-{=r?n-hbt@n2qBbMpWI
delta 277
zcmaD5-w;#Jz{tSBz|GLWz|3IaAFOZ0FT`d96xjpB3CX#M1yu{nRx&U!$^iLu(i4jd
zfV2RRzXM2fq~}zodD{B^0P+toFld@&q$Z|_`&yPTFlc=N%9~{X1vt1_%orH7b%1=8
zjNFonK6MU$AU^=epOKTF?3g%BqnigP@B%2{mz!8oz+lPvfq_BC0?1d$OUzB3>BadQ
z$d3S8(pHdPTmp0`5a>Pt$ulrBf0}r}o>6QPV=SZC<~YXbn#v&m0KwhwE&B2NHeVUI
pS-||8pMJH07$AD`7aem(waGfVGbW$V_2U4Ff;6dZmeV`L2mlimLbw0`
diff --git a/static/fonts/icomoon.woff b/static/fonts/icomoon.woff
index ea44191b445a0c58462f38496773d5eae15315b7..c9dcc404188e1645b4ab80eaa6c751bb8e336bf4 100644
GIT binary patch
delta 803
zcmYjPOKTHR6ux)n&V9{0$z+T)A!#(JG>zIMjf!Xy+J&^*g-eTuv^Gd%3oTmRWFsQ(
zY4ap@nZD|c=capU{kB&}Y~H*?N+&vzcoyg&W&Z0yp>@o|I^oP7mk
zs`uWH?fu0aHw)2Lx!$ZTB7_^jt|?r3a`D+*Z3#GyK+Jpa=H@S-1I|Kd&{G)r@@}m$
zTbn^>s1I^R6vFA~Y6D2%PC>j&ft#e-XfCe+2k7=#&2x8tp$>T`iZLLk6z&W>y49?$
zK;t45Md=O>;m;Rp%~{~C!jRV#?jILF78jP5VH+5xm#2fL>tEXzK8nNk1fIs>`g44D
zPYW*oY!IYxAP+r2j}eOY)4-3#OPS%3ieJuUGhr!6;c&}>Y&n-N;%q-<4(?hIi*H~5
z?*y@bIV{LliWr1ltCV`0ma1GD9;qI|l}_R9lx?R{R<}K?;7pKd4vmfu={fcM`Qi0J
zug|svAsoXn>O#t$zHk|n2BBK#Fk;x8$E--&aT8K{)S!^*3K`l?s^uSWla7-x%?M?*
zt`j0Cbr^SfG%1DDG@Tg)K0^sp%RcL{e%qW*JlxLlD9Z>a;lXq=?&A(uXxHSi?0a!((HZK@%wxuDd&c?Ko`5
zU>(6l=rEg7vs;2dMeYe1x2%-!_E~lyI~BBvUoR%%n_~c+`1z*~&$x%bA(M3C&F`OX
iD`!&&r#&-R#rgKy;63=G+rxz^9$fDT^XoSXZ}DFj^LEVu
delta 319
zcmZ3Ja3)5$+~3WOfsp|S^n4h&!SrN71IdXx!u3YUxrqe~42&5-(K#SowXkeudSWq9
zjEM!v=YV2?^qk5xpcoGWgQf`xd)oT`$Vg30VPMd90ID$qVR2u}k_@0AP^<#TR{>!T
zZWgnQ+!7#P8)W_r5bjgw;LpiV2CDPZ0UE~O2g1`dx_NRFD}Wa3v;g@EU~I|wAulmE
z6)3g@=*Tt@p6SK;yCA=~1n3zZxH@Jai<$Y;WFAI)MzP60jIoSjn^!ST*Hi}knt|c&
y_ZIzlew(ih+$=zq3=B6v{Q?>Wp(i`&nlq|RuF#z^Swznd%{^HYqWq+G(SBL2N}llSwct#9kpnsAa6RRR^VmQcn>}
z2yN9mg9sXHwKTR4YH4k4Eop+5*g87DzQ5s}&*$9px#yhEy`OW>z2BQgBv?rwM;-?N
zKpJmtVFy~buL_d_$46u34A4l1n&BN_pv1zirGoQg!PewZ0D!*wst|^iw=U>Z2)p7G
zW*?=qcned9$RZ)r-P(by)X!TU8d}`-
z6})MrG<082&SM4lH`XVWK1lI%%*IIHlFL*55Qr!{CoVIInMskqo#W9Km{75)Y(KVq7VE9KZd6irf37Gd4re@TurnLF&Qwe7`Tbk$
zlfO3>dVWrpIhvpXc?)@#F_~9Bj|0kq*xWJfT~t)8v3n%#yGD#hlk*^ia)emFuZxtx
zsl9D6QQa6`ETxUo)o?i8eVLD%!0O;cB^?FaW1%Yt8)uItNTl~7;Bd;3jUUPvGfW<$
zZ_^sJV-okusBw{dg*38MqKut9OwzEI(NgF`79CZ2$%EyNm2GWpso2tJG`Zv~cPGR(
zI84n;Ur8hjQ^PgrJoS??Q<6yVnz;3^EH6ms&g1F}s;v^c3R)+g9B7Oi74WGaUbjUo
z{GFru^Tz7SDu*iarwYpF{cXWQNUQuC1Oj26_wK6`_V~nw?f||geV+N
zKsJOYcYG0!jev`U6H8tf&qnVl}a+{i_rpOo!rcnyf8P;f5Ux
z4-cPyGyy3P?@mlO=H}-1_OeFNYXZS0*uDHn0^y{yi%Ub`Omn_I8{VI(k}|hGNfb|8
zZHJ8oTrTH0wU$nej+SMee8KnEp>Rr5WzB+jH$0nG!*BX;-bM8>yTD^d&AuNkw~E{m
zG1Z^YCw0J}`2e95Mw-<+(=#%XrQUpf>8S(TJ@kPwhd>~xpGiZD_s7P@^7|UtlqazY
zEcDY}DwXv_HC$28V%%JdzWntsUJRQ0&|PRJ|a
zuNfH`9svQ`a@(tUzhD#B*4I-8-?4iZ
z0Y(bqUa!yNaSK%+=o{UJd0A+3bT>>FZY<*rwcb00@~FtP(4?y~86mS*Eb
z*>9vyr&+)^%~MX+a01=jiq$V08P#ZX)Dq9eQK=fDZzZfUmG89qiSM&mEKbkB#zuX?
z7T0XMYkX-FjXf<1Hpb!V)&5!goV+}#l%7OIJ%)))?n7n6$kNLl45Cq{-hEq6%nqY_
z&j4t@aUehH2QSnMB19%$3l5giBow7$!xSLnF|nvKU9F+Z7j$*+)$=QVD$L5s$stCg
z;bz|6<(_OPa(Oe3r=qK?Tkv$n<3cKz`|mBW*dJe`c-Q%2y_2^7jeU$io!;OxnCCMC
z-YQm2jWnSubSfyFWIg44hfnPhy>279x&40QKn%%0pw6~O$wHyf6@kDUgLG{u85pnu
zi_ml(?ugk57EU)+9FWi1i7is+*1;EG#bAh%cK>_cy<7Ne^_nIYixuGuoQrYs#bGMd
z$9TBn(;x%v4mZfYzRTUN-a&8LNgW--%RSqI$(-6HdWmk$z%7}ffsL}?z9xdO2;R(?
zUsUMoyOfmfD~T9}>HKen|5rc)SUOCW!d6NGOCZ>g$yq-_O!Ne18I~BNP#(>Wa6))t
zU~0l76kZvqb3%BlN?joZ=-tW8v^tANLWq4_b*PCJd;7dw`-&bUL3QMNO`~#JsG}FZBw{q;Q
z
@@ -329,6 +329,18 @@
{% endif %}
+ {% if enable_shell %}
+
+
+
+ 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 %}