Source code for jflib.send_nsca

# https://github.com/Josef-Friedrich/send_nsca/commit/f2a1ba12f923cb820457a8c9c8bd3415c8358966
"""
send_nsca.py: A replacement for the C-based send_nsca, able
to be run in pure-python. Depends on PyCrypto and Python >= 2.6.

Heavily inspired by (and protocol-compatible with) the original send_nsca,
written by Ethan Galstad <nagios@nagios.org>, which was available under
the terms of the GNU General Public License v2.

Not quite feature-complete. The simpler encryption algorithms (null,
XOR, DES, 3DES, Blowfish, ARC2, and CAST) work, but AES doesn't work
(the AES that nsca uses isn't compatible with PyCrypto's for reasons
that I haven't yet determined). Also, ARC4 is broken upstream, and I
didn't fix it.

Copyright (C) 2012 Yelp, Inc.
Written by James Brown <jbrown@yelp.com>

This software is available under the terms of the Lesser GNU Public
License, Version 2.1

Forked from `this repository at Github <https://github.com/Yelp/send_nsca/commit/8ad96b069c2bc200ca38dcc6fad0c7a8b3e47475>`_

Changes on this fork:
---------------------

* All code in one file.
* PEP 8
* Python 3 only
* Add arguments: `password` and `encryption_method`. No need for a
  configuration file anymore.
* Works without `pycrypto` (only `encryption_method` 1 works)

Another interesting Python package that sends NSCA messages is
`pynsca <https://github.com/djmitche/pynsca>`_.

Usage
-----

The convenience function:

.. code:: python

    from send_nsca import send_nsca, STATE_OK
    send_nsca.send_nsca(
        status=STATE_OK,
        host_name='host',
        service_name='service',
        text_output='output',
        remote_host='1.2.3.4',
        password='1234'
        encryption_method=1
    )

The class:

.. code:: python

    from send_nsca import NscaSender, STATE_OK
    nsca = NscaSender(remote_host='1.2.3.4', password='1234', encryption_method=1)
    nsca.send_service(status=STATE_OK, host_name='host', service_name='service',
                    text_output='output')
    nsca.disconnect()
"""  # noqa: E501


import array
import binascii
import logging
import math
import os
import random
import socket
import struct
from . import six
from typing import Union

try:
    import Crypto.Cipher.AES
    import Crypto.Cipher.ARC2
    import Crypto.Cipher.Blowfish
    import Crypto.Cipher.DES
    import Crypto.Cipher.DES3
    import Crypto.Cipher.CAST
    # pycryptodome has no randpool
    # import Crypto.Util.randpool
except ImportError:
    from unittest.mock import Mock
    Crypto = Mock()

version_info = (0, 1, 4, 1)
__version__ = ".".join(map(str, version_info)) + '-yelp1'
__author__ = "James Brown <jbrown@yelp.com>"

MAX_PASSWORD_LENGTH = 512
MAX_HOSTNAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 128
MAX_PLUGINOUTPUT_LENGTH = 512

_TRANSMITTED_IV_SIZE = 128

PACKET_VERSION = 3

DEFAULT_PORT = 5667

log = logging.getLogger('send_nsca')

# NAGIOS ########

STATE_OK = 0
STATE_WARNING = 1
STATE_CRITICAL = 2
STATE_UNKNOWN = 3

States = {
    STATE_OK: 'OK',
    STATE_WARNING: 'WARNING',
    STATE_CRITICAL: 'CRITICAL',
    STATE_UNKNOWN: 'UNKNOWN',
}


# CIPHERS AND CRYPTERS IMPLEMENTATION ########

crypters = {}


class _MetaCrypter(type):
    def __new__(clsarg, *args, **kwargs):
        cls = super(_MetaCrypter, clsarg).__new__(clsarg, *args, **kwargs)
        if cls.crypt_id >= 0:
            crypters[cls.crypt_id] = cls
        return cls


[docs]class Crypter(six.with_metaclass(_MetaCrypter, object)): crypt_id = -1 def __init__(self, iv, password, random_generator): self.iv = iv self.password = password self.random_generator = random_generator
[docs] def encrypt(self, value): raise NotImplementedError('Implement me!')
[docs]class UnsupportedCrypter(Crypter): crypt_id = -1
[docs]class NullCrypter(Crypter): crypt_id = 0
[docs] def encrypt(self, value): return value
[docs]class XORCrypter(Crypter): crypt_id = 1
[docs] def encrypt(self, value): value_s = six.iterbytes(value) repeated_iv = six.iterbytes( list(int(math.ceil(float(len(value)) / len(self.iv))) * self.iv)) repeated_password = six.iterbytes( list(int(math.ceil(float(len(value)) / len(self.password))) * self.password)) # noqa: E501 xor1 = [a ^ b for a, b in zip(value_s, repeated_iv)] xor2 = [a ^ b for a, b in zip(xor1, repeated_password)] return b''.join(map(six.int2byte, xor2))
[docs]class CryptoCrypter(Crypter): crypt_id = -1 # override this CryptoCipher = Crypto.Cipher.DES # usually override this key_size = 7 # rarely override this iv_size = None def __init__(self, *args): super(CryptoCrypter, self).__init__(*args) key = self.password iv = self.iv if self.iv_size is not None: iv_size = self.iv_size else: iv_size = self.CryptoCipher.block_size if len(self.password) >= self.key_size: key = self.password[:self.key_size] else: key += b'\0' * (self.key_size - len(self.password)) if len(self.iv) >= self.CryptoCipher.block_size: iv = self.iv[:iv_size] else: iv += self.random_generator(iv_size - self.iv) self.crypter = self.CryptoCipher.new( key, self.CryptoCipher.MODE_CFB, iv)
[docs] def encrypt(self, value): return self.crypter.encrypt(value)
[docs]class DESCrypter(CryptoCrypter): crypt_id = 2 CryptoCipher = Crypto.Cipher.DES key_size = 8
[docs]class DES3Crypter(CryptoCrypter): crypt_id = 3 CryptoCipher = Crypto.Cipher.DES3 key_size = 24
[docs]class CAST128Crypter(CryptoCrypter): crypt_id = 4 CryptoCipher = Crypto.Cipher.CAST key_size = 16
[docs]class CAST256Crypter(UnsupportedCrypter): crypt_id = 5
[docs]class XTEACrypter(UnsupportedCrypter): crypt_id = 6
[docs]class ThreeWayCrypter(UnsupportedCrypter): crypt_id = 7
[docs]class BlowFishCrypter(CryptoCrypter): crypt_id = 8 CryptoCipher = Crypto.Cipher.Blowfish key_size = 56
[docs]class TwoFishCrypter(UnsupportedCrypter): crypt_id = 9
[docs]class Loki97Crypter(UnsupportedCrypter): crypt_id = 10
[docs]class RC2Crypter(CryptoCrypter): crypt_id = 11 CryptoCipher = Crypto.Cipher.ARC2 key_size = 128
[docs]class RC4Crypter(UnsupportedCrypter): crypt_id = 12
# We actually can support this one, but the server-side nsca is broken # for it (since server-side always runs in CFB mode, even though RC4 # doesn't have a CFB mode)
[docs]class RC6Crypter(UnsupportedCrypter): crypt_id = 13
[docs]class AES128Crypter(CryptoCrypter): crypt_id = 14 CryptoCipher = Crypto.Cipher.AES key_size = 16
[docs]class AES192Crypter(CryptoCrypter): crypt_id = 15 CryptoCipher = Crypto.Cipher.AES key_size = 24
[docs]class AES256Crypter(CryptoCrypter): crypt_id = 16 CryptoCipher = Crypto.Cipher.AES key_size = 32
# WIRE PROTOCOL IMPLEMENTATION ######## _data_packet_format = '!hxxLLh%ds%ds%dsxx' % ( MAX_HOSTNAME_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_PLUGINOUTPUT_LENGTH) _init_packet_format = '!%dsL' % (_TRANSMITTED_IV_SIZE,)
[docs]def get_random_alphanumeric_bytes(bytesz): return ''.join(chr(random.randrange(ord('0'), ord('Z'))) for _ in range(bytesz)).encode('US-ASCII')
def _pack_packet(hostname, service, state, output, timestamp): """This is more complicated than a call to struct.pack() because we want to pad our strings with random bytes, instead of with zeros.""" requested_length = struct.calcsize(_data_packet_format) packet = array.array('B', b'\0' * requested_length) # first, pack the version, initial crc32, timestamp, and state # (collectively:header) header_format = '!hxxLLh' offset = struct.calcsize(header_format) struct.pack_into('!hxxLLh', packet, 0, PACKET_VERSION, 0, timestamp, state) # next, pad & pack the hostname hostname = hostname + b'\0' if len(hostname) < MAX_HOSTNAME_LENGTH: hostname += get_random_alphanumeric_bytes( MAX_HOSTNAME_LENGTH - len(hostname)) struct.pack_into('!%ds' % (MAX_HOSTNAME_LENGTH,), packet, offset, hostname) offset += struct.calcsize('!%ds' % (MAX_HOSTNAME_LENGTH,)) # next, pad & pack the service description service = service + b'\0' if len(service) < MAX_DESCRIPTION_LENGTH: service += get_random_alphanumeric_bytes( MAX_DESCRIPTION_LENGTH - len(service)) struct.pack_into( '%ds' % (MAX_DESCRIPTION_LENGTH, ), packet, offset, service) offset += struct.calcsize('!%ds' % (MAX_DESCRIPTION_LENGTH)) # finally, pad & pack the plugin output output = output + b'\0' if len(output) < MAX_PLUGINOUTPUT_LENGTH: output += get_random_alphanumeric_bytes( MAX_PLUGINOUTPUT_LENGTH - len(output)) struct.pack_into( '%ds' % (MAX_PLUGINOUTPUT_LENGTH, ), packet, offset, output) # compute the CRC32 of what we have so far crc_val = binascii.crc32(packet) & 0xffffffff struct.pack_into('!L', packet, 4, crc_val) return packet.tostring() # MAIN CLASS IMPLEMENTATION ########
[docs]class ConfigParseError(Exception): def __init__(self, filename, lineno, msg): self.filename = filename self.lineno = lineno self.msg = msg def __str__(self): return 'Configuration parsing error: [%s:%d] %s' % ( self.filename, self.lineno, self.msg) def __repr__(self): return 'ConfigParseError(%s, %d, %s)' % ( self.filename, self.lineno, self.msg)
def _bytes(value: Union[str, bytes]) -> bytes: """Convert str into bytes. To allow `bytes` input as well as `str` for the arguemnts `host`, `service` and `description`. :param value: Input as str or bytes. """ if isinstance(value, bytes): return value elif isinstance(value, str): return value.encode('ascii') raise ValueError('Input {} is not an instance of bytes or str' .format(value))
[docs]class NscaSender(object): """Send NSCA messages. :param remote_host: host to send to :param config_path: path to the nsca config file. Usually `/etc/send_nsca.cfg`. None to disable. :param port: The port the NSCA server listen to. :param send_to_all: If true, will repeat your message to *all* hosts that match the lookup for remote_hos0 :param password: The NSCA password. Max password length: 512 :param encryption_method: An integer. The NSCA encryption method. The supported encryption methods are: 0 1 2 3 4 8 11 14 15 16' """ def __init__(self, remote_host: str, config_path: str = '/etc/send_nsca.cfg', port: int = DEFAULT_PORT, timeout: int = 10, send_to_all: bool = True, password: str = '', encryption_method: int = 0): self.port = port self.timeout = timeout self.encryption_method = 0 self.password = password self.remote_host = remote_host self.send_to_all = send_to_all self._conns = [] self._connected = False self.Crypter = Crypter self._cached_crypters = {} self.random_generator = os.urandom if config_path is not None and not \ (self.password or self.encryption_method): with open(config_path, 'rb') as f: self.parse_config(f, config_path=config_path) else: if encryption_method: self._setup_encryption_method(encryption_method) if password: self._setup_password(bytes(password.encode())) def _setup_encryption_method(self, encryption_method, config_path=None, line_no=0): self.encryption_method = encryption_method if self.encryption_method not in crypters.keys(): raise ConfigParseError( config_path, line_no, 'Unrecognized uncryption method %d' % # noqa: E501 (self.encryption_method,)) self.Crypter = crypters[self.encryption_method] if issubclass(self.Crypter, UnsupportedCrypter): raise ConfigParseError( config_path, line_no, 'Unsupported cipher type %d (%s)' % # noqa: E501 (self.Crypter.crypt_id, self.Crypter.__name__)) def _setup_password(self, password, config_path=None, line_no=0): if len(password) > MAX_PASSWORD_LENGTH: raise ConfigParseError( config_path, line_no, 'Password too long; max %d' % MAX_PASSWORD_LENGTH) assert isinstance(password, bytes), password self.password = password
[docs] def parse_config(self, config_file_object, config_path: str = ''): config_file_object.seek(0) for line_no, line in enumerate(config_file_object): if b'=' not in line or line.lstrip().startswith(b'#'): continue key, value = [res.strip() for res in line.split(b'=')] try: if key == b'password': self._setup_password(value, config_path, line_no) elif key == b'encryption_method': self._setup_encryption_method(int(value), config_path, line_no) else: raise ConfigParseError( config_path, line_no, 'Unrecognized key \'%s\'' % (key,)) except ConfigParseError: raise except BaseException: raise ConfigParseError( config_path, line_no, 'Could not parse value \'%s\' for key \'%s\'' % # noqa: E501 (value, key))
def _check_alert(self, host: bytes = None, service: bytes = None, state: int = None, description: bytes = None): # state if state not in States.keys(): raise ValueError('state %r should be one of {%s}' % ( state, ','.join(map(str, States.keys())))) # host if not isinstance(host, bytes): raise ValueError('host %r must be a non-unicode string' % (host)) if len(host) > MAX_HOSTNAME_LENGTH: raise ValueError( 'host %r too long (max length %d)' % (host, MAX_HOSTNAME_LENGTH)) # description if not isinstance(description, bytes): raise ValueError( 'plugin output %r must be a non-unicode string' % (description)) if len(description) > MAX_PLUGINOUTPUT_LENGTH: raise ValueError( 'plugin output %r too long (max length %d)' % (description, MAX_PLUGINOUTPUT_LENGTH)) # service if service is not None: if not isinstance(service, bytes): raise ValueError( 'service %r must be a non-unicode string' % (service)) if len(service) > MAX_DESCRIPTION_LENGTH: raise ValueError( 'service %r too long (max length %d)' % (service, MAX_DESCRIPTION_LENGTH))
[docs] def send_service(self, host: Union[str, bytes], service: Union[str, bytes], state: int, description: Union[str, bytes]): """ :param host: Host name to report as :param service: Service to report as :param state: Integer describing the status :param description: Freeform text, should be under 512b """ host = _bytes(host) service = _bytes(service) description = _bytes(description) self._check_alert( host=host, service=service, state=state, description=description) self.connect() for conn, iv, timestamp in self._conns: if conn not in self._cached_crypters: self._cached_crypters[conn] = self.Crypter( iv, self.password, self.random_generator) crypter = self._cached_crypters[conn] packet = _pack_packet(host, service, state, description, timestamp) packet = crypter.encrypt(packet) conn.sendall(packet)
[docs] def send_host(self, host: Union[str, bytes], state: int, description: Union[str, bytes]): """ :param host: Host name to report as :param state: Integer describing the status :param description: Freeform text, should be under 512b """ return self.send_service(host, b'', state, description)
def _sock_connect(self, host, port, timeout=None, connect_all=True): conns = [] for ( family, socktype, proto, canonname, sockaddr) in socket.getaddrinfo( host, port, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, 0): try: s = socket.socket(family, socktype, proto) s.connect(sockaddr) conns.append(s) if timeout is not None: s.settimeout(timeout) if not connect_all: break except socket.error: continue if not conns: raise socket.error( 'could not connect to %s:%d' % (self.remote_host, self.port)) return conns def _handshake_all(self, conns): handshakes = [] for conn in conns: iv, timestamp = self._read_init_packet(conn) handshakes.append((conn, iv, timestamp)) return handshakes
[docs] def connect(self): if self._connected: return conns = self._sock_connect( self.remote_host, self.port, self.timeout, connect_all=self.send_to_all) self._conns.extend(self._handshake_all(conns)) self._connected = True
[docs] def disconnect(self): if not self._connected: return for conn, _, _ in self._conns: conn.close() self._conns = [] self._connected = False
def _read_init_packet(self, fd): init_packet = fd.recv(struct.calcsize(_init_packet_format)) transmitted_iv, timestamp = struct.unpack( _init_packet_format, init_packet) return transmitted_iv, timestamp def __del__(self): self.disconnect()
# HELPER FUNCTIONS ########
[docs]def send_nsca(status: int, host_name: Union[str, bytes], service_name: Union[str, bytes], text_output: Union[str, bytes], remote_host: str, **kwargs): """Helper function to easily send a NSCA message (wraps .nsca.NscaSender) :param status: Integer describing the status :param host_name: Host name to report as :param service_name: Service to report as :param text_output: Freeform text, should be under 512b :param remote_host: Host name to send to :param str config_path: path to the nsca config file. Usually `/etc/send_nsca.cfg`. None to disable. :param int port: The port the NSCA server listen to. :param bool send_to_all: If true, will repeat your message to *all* hosts that match the lookup for remote_hos0 :param str password: The NSCA password. Max password length: 512 :param int encryption_method: An integer. The NSCA encryption method. The supported encryption methods are: 0 1 2 3 4 8 11 14 15 16' """ try: n = NscaSender(remote_host=remote_host, **kwargs) n.send_service(host_name, service_name, status, text_output) n.disconnect() except Exception as e: log.error("Unable to send NSCA packet to %s for %s:%s (%s)", remote_host, host_name, service_name, str(e))
[docs]def nsca_ok(host_name: Union[str, bytes], service_name: Union[str, bytes], text_output: Union[str, bytes], remote_host: str, **kwargs): """Wrapper for the send_nsca() function to easily send an OK :param host_name: Host name to report as :param service_name: Service to report as :param text_output: Freeform text, should be under 512b :param remote_host: Host name to send to :param str config_path: path to the nsca config file. Usually `/etc/send_nsca.cfg`. None to disable. :param int port: The port the NSCA server listen to. :param bool send_to_all: If true, will repeat your message to *all* hosts that match the lookup for remote_hos0 :param str password: The NSCA password. Max password length: 512 :param int encryption_method: An integer. The NSCA encryption method. The supported encryption methods are: 0 1 2 3 4 8 11 14 15 16' """ return send_nsca( status=STATE_OK, host_name=host_name, service_name=service_name, text_output=text_output, remote_host=remote_host, **kwargs )
[docs]def nsca_warning(host_name: Union[str, bytes], service_name: Union[str, bytes], text_output: Union[str, bytes], remote_host: str, **kwargs): """Wrapper for the send_nsca() function to easily send a WARNING :param host_name: Host name to report as :param service_name: Service to report as :param text_output: Freeform text, should be under 512b :param remote_host: Host name to send to :param str config_path: path to the nsca config file. Usually `/etc/send_nsca.cfg`. None to disable. :param int port: The port the NSCA server listen to. :param bool send_to_all: If true, will repeat your message to *all* hosts that match the lookup for remote_hos0 :param str password: The NSCA password. Max password length: 512 :param int encryption_method: An integer. The NSCA encryption method. The supported encryption methods are: 0 1 2 3 4 8 11 14 15 16' """ return send_nsca( status=STATE_WARNING, host_name=host_name, service_name=service_name, text_output=text_output, remote_host=remote_host, **kwargs )
[docs]def nsca_critical(host_name: Union[str, bytes], service_name: Union[str, bytes], text_output: Union[str, bytes], remote_host: str, **kwargs): """Wrapper for the send_nsca() function to easily send a CRITICAL :param host_name: Host name to report as :param service_name: Service to report as :param text_output: Freeform text, should be under 512b :param remote_host: Host name to send to :param str config_path: path to the nsca config file. Usually `/etc/send_nsca.cfg`. None to disable. :param int port: The port the NSCA server listen to. :param bool send_to_all: If true, will repeat your message to *all* hosts that match the lookup for remote_hos0 :param str password: The NSCA password. Max password length: 512 :param int encryption_method: An integer. The NSCA encryption method. The supported encryption methods are: 0 1 2 3 4 8 11 14 15 16' """ return send_nsca( status=STATE_CRITICAL, host_name=host_name, service_name=service_name, text_output=text_output, remote_host=remote_host, **kwargs )
[docs]def nsca_unknown(host_name: Union[str, bytes], service_name: Union[str, bytes], text_output: Union[str, bytes], remote_host: str, **kwargs): """Wrapper for the send_nsca() function to easily send an UNKNONW :param host_name: Host name to report as :param service_name: Service to report as :param text_output: Freeform text, should be under 512b :param remote_host: Host name to send to :param str config_path: path to the nsca config file. Usually `/etc/send_nsca.cfg`. None to disable. :param int port: The port the NSCA server listen to. :param bool send_to_all: If true, will repeat your message to *all* hosts that match the lookup for remote_hos0 :param str password: The NSCA password. Max password length: 512 :param int encryption_method: An integer. The NSCA encryption method. The supported encryption methods are: 0 1 2 3 4 8 11 14 15 16' """ return send_nsca( status=STATE_UNKNOWN, host_name=host_name, service_name=service_name, text_output=text_output, remote_host=remote_host, **kwargs )