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 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 %}