-
-
Notifications
You must be signed in to change notification settings - Fork 170
New Monitor: remote queries via ssh #1398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 10 commits
ca45f85
cb41f6c
4cb591c
fa72565
475bbee
7ee06a5
a4cae21
246011f
fd8abdd
bae0764
954e8c8
4ccfe69
074399d
7e409c2
1e9b02f
f5bb0ef
1f88704
c3a0d2b
c2bddf8
651745f
9f7c760
10c2e14
3e44514
f665a31
63a4187
931e705
8362dba
9959c7e
b581907
b8d73ee
b75689c
97566cc
2e087ed
62cf843
2239702
c931224
8cb51f8
d2c93d0
0b219cf
06f9dd4
093302b
275cd0a
9b6dd68
5c798ad
0c0348c
7ecff22
b5d2f56
14c993f
f08db80
f5ab22b
c406477
c0b5d2d
597d247
e6408bb
7bb1d29
b0c0b5d
7969cfc
fa690f2
09f0979
aa483be
ed7f97e
a28f638
bca5bfe
fe4481d
55dbd73
8a44e58
0cceeed
ada3bfb
6b947b4
17b8603
aec1d41
486b2b6
2f06602
dcb2c12
fea7d5a
d7daf07
4480790
b06d65b
71f5c54
696863c
df1442e
5dd25f6
cbebdb1
b29847a
0ec151b
eb92c0f
ad94d9d
72307f6
00e99e4
64d31b0
ffbe7b2
248d8c2
e79aae1
f7dc196
9b5711e
214e4f0
7267de1
68f3358
2b3284e
0e18428
11e7a26
e800221
40760a2
eaa32df
eee69ba
c879c58
00dd778
c5fe7a0
aa6cd5d
7ccca0c
3e116d5
4453a2f
f41778e
d4cc356
c9fdf7d
b6e762a
8041217
6d9153f
378ca2b
b1a41ce
114db3f
de4b6b1
110493e
f3b5976
583bae8
705b918
dd5a971
c671614
2c976bc
934446d
3b36d85
9fbb1e4
91a7f98
4ac6aa7
24e804a
0730f1d
26ae960
ef4e516
f1bca57
c1e0f25
6b55a0a
2e89de2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -45,3 +45,4 @@ docs/_build | |
.tox | ||
pyrightconfig.json | ||
simplemonitor/html/node_modules | ||
.venv |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
remote_ssh - Monitor Remote Entities With SSH | ||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||
|
||
``remote_ssh`` is a generic Monitor intended to be used for remote machines that do not have Simplemonitor installed. | ||
It connects with ``ssh`` to run a remote command, then parses the reply and monitors the result. | ||
|
||
.. warning:: | ||
This Monitor uses bare `ssh` commands, in the context of the user they are run againt. This means that you can break everything and a little bit more if you are not careful. | ||
|
||
You should also consider the security risk of having an SSH private key on the monotoring machine. And while we are at the topic of cybersecurity, you should ensure that the SSH command is not injected (this is not liklely if you do not dynamically generate `monitors.ini`) | ||
|
||
The sequence of this Monitor is to send to ``ssh_username@target_host:target_port`` the ``command`` via SSH, retrieve the output and parse it with ``regex`` to extract a value. | ||
|
||
This value is then compared with ``target_value`` with ``operator``. A failed comparison raises an alert. | ||
|
||
.. info:: | ||
This Monitor is limited in the edge cases it can manage. If you use a command with a predictible output and a proper regex you are good. If you start to tinker or have a regex that is not solid you may crash your Monitor (which just means you have to correct something) | ||
|
||
|
||
.. confval:: description | ||
|
||
:type: string | ||
:required: false | ||
|
||
The description of the Monitor which is sent back upon configuring an instance of this Monitor. Fallsback to a generic description. | ||
|
||
.. confval:: target_hostname | ||
|
||
:type: string | ||
:required: true | ||
|
||
The remote host to run the command on. | ||
|
||
.. confval:: target_port | ||
|
||
:type: int | ||
:required: true | ||
|
||
The port SSH runs on (on the remote server). Defaults to 22. | ||
|
||
.. confval:: ssh_username | ||
|
||
:type: string | ||
:required: true | ||
|
||
Login username. | ||
|
||
.. confval:: ssh_private_key_path | ||
|
||
:type: string | ||
:required: true | ||
|
||
The absolute path to the OpenSSH *private* key to login on the remote server. The remote server must have a corresponding entry in ``authorized_keys`` for the user that connects. | ||
|
||
.. confval:: command | ||
|
||
:type: string | ||
:required: true | ||
|
||
The command to run. It will use the context of the logged-in user and it is recommended to use absolute pathnames for commands. It is best to test the command by logging in as ``ssh_username`` and trying the command at the prompt. | ||
|
||
.. confval:: regex | ||
|
||
:type: string | ||
:required: true | ||
|
||
The regular expression the output of the command above will be matched to. | ||
|
||
* Make sure to have one matching group - this is the value that will be checked | ||
* Do not escape the sequences (i.e. use ``\s`` in the configuration when you mean "whitespace") | ||
* A fantastic site to check your regex is https://regex101.com (do not block their ads!) | ||
|
||
.. confval:: result_type | ||
|
||
:type: string | ||
:required: true | ||
|
||
The type of the extracted value. Can be ``str`` (a string) or ``int`` (a number) | ||
|
||
.. confval:: target_value | ||
|
||
:type: string | ||
:required: true | ||
|
||
The value to compare extracted results with. Must be of the same type as the extracted value. | ||
|
||
.. confval:: operator | ||
|
||
:type: string | ||
:required: true | ||
|
||
The operator that compares the extracted value with ``target_value``. The possible operators are: | ||
|
||
* ``equals`` - works with numbers and strings | ||
* ``not_equals`` - works with number and strings | ||
* ``greater_than`` - works with numbers | ||
* ``less_than`` - works with numbers | ||
|
||
.. confval:: success_message | ||
|
||
:type: string | ||
:required: false | ||
|
||
A templated message for monitoring success. It must be a string `compatible with ``.format()`` https://docs.python.org/3/tutorial/inputoutput.html#the-string-format-method`_. You can use one bracket (``{}``) which will be replaced with the extracted value. | ||
|
||
An example of a full configuration that checks if the ``/dev/sda`` disk on machine ``srv.example.com`:2255`` has more that 10% of free space available: | ||
|
||
.. code-block:: | ||
|
||
[srv] | ||
type = remote_ssh | ||
description=check disk space on srv | ||
command = df -k | grep /dev/sda | ||
ssh_private_key_path = C:\Users\mark\.ssh\srv.private.openssh | ||
ssh_username = root | ||
target_hostname = srv.example.com | ||
target_port = 2255 | ||
regex = .*\s(\d+)% | ||
operator = greater_than | ||
target_value = 10 | ||
result_type = int | ||
success_message=free disk {}% |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
""" | ||
Remote command via ssh to check for stuff without simplemonitor on the remote target | ||
|
||
Input: | ||
- command to execute | ||
- regex to extract the monitored value | ||
- expected value | ||
- logic to apply | ||
""" | ||
|
||
from venv import logger | ||
from .monitor import Monitor, register | ||
from enum import Enum | ||
import paramiko | ||
import re | ||
from typing import Tuple, cast | ||
|
||
|
||
class Operator(Enum): | ||
EQUALS = "equals" | ||
NOT_EQUALS = "not_equals" | ||
GREATER_THAN = "greater_than" | ||
LESS_THAN = "less_than" | ||
|
||
|
||
class OperatorType(Enum): | ||
STRING = "str" | ||
INTEGER = "int" | ||
|
||
|
||
@register | ||
class MonitorRemoteSSH(Monitor): | ||
|
||
monitor_type = "remote_ssh" | ||
|
||
def __init__(self, name: str, config_options: dict) -> None: | ||
super().__init__(name, config_options) | ||
# description | ||
self.description = cast(str, self.get_config_option("description", required=False)) # maybe define default here instead of a try: ? | ||
self.success_message = cast(str, self.get_config_option("success_message", required=False, default="it worked")) | ||
# ssh configuration | ||
self.command = cast(str, self.get_config_option("command", required=True)) | ||
self.ssh_private_key_path = cast(str, self.get_config_option("ssh_private_key_path", required=True)) | ||
self.ssh_username = cast(str, self.get_config_option("ssh_username", required=True)) | ||
self.target_hostname = cast(str, self.get_config_option("target_hostname", required=True)) | ||
self.target_port = cast(int, self.get_config_option("target_port", required=False, default="22")) | ||
# operator logic | ||
self.operator = cast( | ||
str, | ||
self.get_config_option( | ||
"operator", | ||
required=True, | ||
allowed_values=[Operator.EQUALS.value, Operator.GREATER_THAN.value, Operator.LESS_THAN.value, Operator.GREATER_THAN.value], | ||
), | ||
) | ||
self.regex = re.compile(cast(str, self.get_config_option("regex", required=True))) | ||
# values to compare, cast to the expected type | ||
self.result_type = cast( | ||
str, | ||
self.get_config_option("result_type", required=True, allowed_values=[OperatorType.INTEGER.value, OperatorType.STRING.value]), | ||
) | ||
match self.result_type: | ||
case OperatorType.INTEGER.value: | ||
self.target_value = cast(int, self.get_config_option("target_value", required=True)) | ||
case OperatorType.STRING.value: | ||
self.target_value = cast(str, self.get_config_option("target_value", required=True)) | ||
|
||
def run_test(self) -> bool: | ||
# run remote command | ||
client = paramiko.SSHClient() | ||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy) | ||
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Show resolved
Hide resolved
|
||
try: | ||
client.connect( | ||
self.target_hostname, | ||
username=self.ssh_username, | ||
key_filename=self.ssh_private_key_path, | ||
port=self.target_port, | ||
) | ||
except TimeoutError: | ||
return self.record_fail(f"connection to {self.target_hostname} timed out") | ||
except ConnectionRefusedError: | ||
return self.record_fail(f"connection to {self.target_hostname} actively refused") | ||
else: | ||
_, stdout, _ = client.exec_command(self.command) | ||
|
||
# extract and cast the actual value | ||
command_result = stdout.read().decode("utf-8") # let's hope for the best | ||
wsw70 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
client.close() # it's only now that we are done with the client | ||
actual_value = re.match(self.regex, command_result).groups()[0] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was wondering if it would be better to require a named group in the regexp, so that can be picked out specifically rather than requiring the user to craft a regexp which makes the first capturing group the one they need? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can do that but what would be the value of several capturing groups? There is just one that will be used anyway. |
||
match self.result_type: | ||
case OperatorType.INTEGER.value: | ||
actual_value = cast(int, actual_value) | ||
case OperatorType.STRING.value: | ||
actual_value = cast(str, actual_value) | ||
|
||
# assess the comparison logic. str and int can be checked for equality, only int can be checked for greater/less than | ||
test_succeeded = False # better be pessimistic | ||
match self.operator: | ||
case Operator.EQUALS.value: | ||
test_succeeded = actual_value == self.target_value | ||
case Operator.NOT_EQUALS.value: | ||
test_succeeded = actual_value != self.target_value | ||
case Operator.GREATER_THAN.value: | ||
test_succeeded = actual_value > self.target_value | ||
case Operator.LESS_THAN.value: | ||
test_succeeded = actual_value < self.target_value | ||
if self.result_type == OperatorType.STRING.value and (self.operator in [Operator.GREATER_THAN.value, Operator.LESS_THAN.value]): | ||
logger.warning(f"strings compared with '{self.operator}'") | ||
if test_succeeded: | ||
return self.record_success(self.success_message.format(actual_value)) | ||
else: | ||
return self.record_fail(f"actual value: {actual_value} | operator: {self.operator} | target value: {self.target_value}") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any value in letting the user also customise the failure message in a similar fashion to the success one? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will require some adjustments - I will have to test how doable it is to pass the format to the actual logging message (right now there is just one "placeholder" ( |
||
|
||
def get_params(self) -> Tuple: | ||
return ( | ||
self.command, | ||
self.description, | ||
self.success_message, | ||
self.regex, | ||
self.target_value, | ||
self.operator, | ||
self.result_type, | ||
self.ssh_private_key_path, | ||
self.ssh_username, | ||
self.target_hostname, | ||
self.target_port, | ||
) | ||
|
||
def describe(self) -> str: | ||
try: | ||
return self.description | ||
except AttributeError: | ||
return "run a remote command, extract its output and apply logic" |
Uh oh!
There was an error while loading. Please reload this page.