diff --git a/custom_components/mikrotik_router/librouteros_custom/__init__.py b/custom_components/mikrotik_router/librouteros_custom/__init__.py new file mode 100644 index 0000000..b345228 --- /dev/null +++ b/custom_components/mikrotik_router/librouteros_custom/__init__.py @@ -0,0 +1,60 @@ +# -*- coding: UTF-8 -*- + +from socket import create_connection +from collections import ChainMap + +from .exceptions import ( + ConnectionClosed, + FatalError, +) +from .connections import SocketTransport +from .protocol import ApiProtocol +from .login import ( + plain, + token, +) +from .api import Api + +DEFAULTS = { + 'timeout': 10, + 'port': 8728, + 'saddr': '', + 'subclass': Api, + 'encoding': 'ASCII', + 'ssl_wrapper': lambda sock: sock, + 'login_method': plain, +} + + +def connect(host, username, password, **kwargs): + """ + Connect and login to routeros device. + Upon success return a Api class. + + :param host: Hostname to connecto to. May be ipv4,ipv6,FQDN. + :param username: Username to login with. + :param password: Password to login with. Only ASCII characters allowed. + :param timeout: Socket timeout. Defaults to 10. + :param port: Destination port to be used. Defaults to 8728. + :param saddr: Source address to bind to. + :param subclass: Subclass of Api class. Defaults to Api class from library. + :param ssl_wrapper: Callable (e.g. ssl.SSLContext instance) to wrap socket with. + :param login_method: Callable with login method. + """ + arguments = ChainMap(kwargs, DEFAULTS) + transport = create_transport(host, **arguments) + protocol = ApiProtocol(transport=transport, encoding=arguments['encoding']) + api = arguments['subclass'](protocol=protocol) + + try: + arguments['login_method'](api=api, username=username, password=password) + return api + except (ConnectionClosed, FatalError): + transport.close() + raise + + +def create_transport(host, **kwargs): + sock = create_connection((host, kwargs['port']), kwargs['timeout'], (kwargs['saddr'], 0)) + sock = kwargs['ssl_wrapper'](sock) + return SocketTransport(sock=sock) diff --git a/custom_components/mikrotik_router/librouteros_custom/api.py b/custom_components/mikrotik_router/librouteros_custom/api.py new file mode 100644 index 0000000..e8c4a17 --- /dev/null +++ b/custom_components/mikrotik_router/librouteros_custom/api.py @@ -0,0 +1,136 @@ +# -*- coding: UTF-8 -*- + +from posixpath import join as pjoin + +from .exceptions import TrapError, MultiTrapError +from .protocol import ( + compose_word, + parse_word, +) +from .query import Query + + +class Api: + + def __init__(self, protocol): + self.protocol = protocol + + def __call__(self, cmd, **kwargs): + """ + Call Api with given command. + Yield each row. + + :param cmd: Command word. eg. /ip/address/print + :param kwargs: Dictionary with optional arguments. + """ + words = (compose_word(key, value) for key, value in kwargs.items()) + self.protocol.writeSentence(cmd, *words) + yield from self.readResponse() + + def rawCmd(self, cmd, *words): + """ + Call Api with given command and raw words. + End user is responsible to properly format each api word argument. + :param cmd: Command word. eg. /ip/address/print + :param args: Iterable with optional plain api arguments. + """ + self.protocol.writeSentence(cmd, *words) + yield from self.readResponse() + + def readSentence(self): + """ + Read one sentence and parse words. + + :returns: Reply word, dict with attribute words. + """ + reply_word, words = self.protocol.readSentence() + words = dict(parse_word(word) for word in words) + return reply_word, words + + def readResponse(self): + """ + Yield each sentence untill !done is received. + + :throws TrapError: If one !trap is received. + :throws MultiTrapError: If > 1 !trap is received. + """ + traps = [] + reply_word = None + while reply_word != '!done': + reply_word, words = self.readSentence() + if reply_word == '!trap': + traps.append(TrapError(**words)) + elif reply_word in ('!re', '!done') and words: + yield words + + if len(traps) > 1: + raise MultiTrapError(*traps) + if len(traps) == 1: + raise traps[0] + + def close(self): + self.protocol.close() + + def path(self, *path): + return Path( + path='', + api=self, + ).join(*path) + + +class Path: + """Represents absolute command path.""" + + def __init__(self, path, api): + self.path = path + self.api = api + + def select(self, key, *other): + keys = (key, ) + other + return Query(path=self, keys=keys, api=self.api) + + def __str__(self): + return self.path + + def __repr__(self): + return "<{module}.{cls} {path!r}>".format( + module=self.__class__.__module__, + cls=self.__class__.__name__, + path=self.path, + ) + + def __iter__(self): + yield from self('print') + + def __call__(self, cmd, **kwargs): + yield from self.api( + self.join(cmd).path, + **kwargs, + ) + + def join(self, *path): + """Join current path with one or more path strings.""" + return Path( + api=self.api, + path=pjoin('/', self.path, *path).rstrip('/'), + ) + + def remove(self, *ids): + ids = ','.join(ids) + tuple(self( + 'remove', + **{'.id': ids}, + )) + + def add(self, **kwargs): + ret = self( + 'add', + **kwargs, + ) + return tuple(ret)[0]['ret'] + + def update(self, **kwargs): + tuple(self( + 'set', + **kwargs, + )) diff --git a/custom_components/mikrotik_router/librouteros_custom/connections.py b/custom_components/mikrotik_router/librouteros_custom/connections.py new file mode 100644 index 0000000..40cd66b --- /dev/null +++ b/custom_components/mikrotik_router/librouteros_custom/connections.py @@ -0,0 +1,37 @@ +# -*- coding: UTF-8 -*- + +from .exceptions import ConnectionClosed + + +class SocketTransport: + + def __init__(self, sock): + self.sock = sock + + def write(self, data): + """ + Write given bytes to socket. Loop as long as every byte in + string is written unless exception is raised. + """ + self.sock.sendall(data) + + def read(self, length): + """ + Read as many bytes from socket as specified in length. + Loop as long as every byte is read unless exception is raised. + """ + data = bytearray() + while len(data) != length: + tmp = None + try: + tmp = self.sock.recv((length - len(data))) + except: + raise ConnectionClosed('Socket recv failed.') + + data += tmp + if not data: + raise ConnectionClosed('Connection unexpectedly closed.') + return data + + def close(self): + self.sock.close() diff --git a/custom_components/mikrotik_router/librouteros_custom/exceptions.py b/custom_components/mikrotik_router/librouteros_custom/exceptions.py new file mode 100644 index 0000000..9eea29b --- /dev/null +++ b/custom_components/mikrotik_router/librouteros_custom/exceptions.py @@ -0,0 +1,52 @@ +# -*- coding: UTF-8 -*- + + +class LibRouterosError(Exception): + """Base exception for all other.""" + + +class ConnectionClosed(LibRouterosError): + """Raised when connection have been closed.""" + + +class ProtocolError(LibRouterosError): + """Raised when e.g. encoding/decoding fails.""" + + +class FatalError(ProtocolError): + """Exception raised when !fatal is received.""" + + +class TrapError(ProtocolError): + """ + Exception raised when !trap is received. + + :param int category: Optional integer representing category. + :param str message: Error message. + """ + + def __init__(self, message, category=None): + self.category = category + self.message = message + super().__init__() + + def __str__(self): + return str(self.message.replace('\r\n', ',')) + + def __repr__(self): + return '{}({!r})'.format(self.__class__.__name__, str(self)) + + +class MultiTrapError(ProtocolError): + """ + Exception raised when multiple !trap words have been received in one response. + + :param traps: TrapError instances. + """ + + def __init__(self, *traps): + self.traps = traps + super().__init__() + + def __str__(self): + return ', '.join(str(trap) for trap in self.traps) diff --git a/custom_components/mikrotik_router/librouteros_custom/login.py b/custom_components/mikrotik_router/librouteros_custom/login.py new file mode 100644 index 0000000..c674cac --- /dev/null +++ b/custom_components/mikrotik_router/librouteros_custom/login.py @@ -0,0 +1,26 @@ +from binascii import unhexlify, hexlify +from hashlib import md5 + + +def encode_password(token, password): + #pylint: disable=redefined-outer-name + token = token.encode('ascii', 'strict') + token = unhexlify(token) + password = password.encode('ascii', 'strict') + hasher = md5() + hasher.update(b'\x00' + password + token) + password = hexlify(hasher.digest()) + return '00' + password.decode('ascii', 'strict') + + +def token(api, username, password): + """Login using pre routeros 6.43 authorization method.""" + sentence = api('/login') + tok = tuple(sentence)[0]['ret'] + encoded = encode_password(tok, password) + tuple(api('/login', **{'name': username, 'response': encoded})) + + +def plain(api, username, password): + """Login using post routeros 6.43 authorization method.""" + tuple(api('/login', **{'name': username, 'password': password})) diff --git a/custom_components/mikrotik_router/librouteros_custom/protocol.py b/custom_components/mikrotik_router/librouteros_custom/protocol.py new file mode 100644 index 0000000..8c31a2b --- /dev/null +++ b/custom_components/mikrotik_router/librouteros_custom/protocol.py @@ -0,0 +1,209 @@ +# -*- coding: UTF-8 -*- + +from struct import pack, unpack +from logging import getLogger, NullHandler + +from .exceptions import ( + ProtocolError, + FatalError, +) + +LOGGER = getLogger('librouteros') +LOGGER.addHandler(NullHandler()) + + +def parse_word(word): + """ + Split given attribute word to key, value pair. + + Values are casted to python equivalents. + + :param word: API word. + :returns: Key, value pair. + """ + mapping = {'yes': True, 'true': True, 'no': False, 'false': False} + _, key, value = word.split('=', 2) + try: + value = int(value) + except ValueError: + value = mapping.get(value, value) + return (key, value) + + +def cast_to_api(value): + """Cast python equivalent to API.""" + mapping = {True: 'yes', False: 'no'} + # this is necesary because 1 == True, 0 == False + if type(value) == int: + value = str(value) + else: + value = mapping.get(value, str(value)) + return value + + +def compose_word(key, value): + """ + Create a attribute word from key, value pair. + Values are casted to api equivalents. + """ + return '={}={}'.format(key, cast_to_api(value)) + + +class Encoder: + + def encodeSentence(self, *words): + """ + Encode given sentence in API format. + + :param words: Words to endoce. + :returns: Encoded sentence. + """ + encoded = map(self.encodeWord, words) + encoded = b''.join(encoded) + # append EOS (end of sentence) byte + encoded += b'\x00' + return encoded + + def encodeWord(self, word): + """ + Encode word in API format. + + :param word: Word to encode. + :returns: Encoded word. + """ + #pylint: disable=no-member + encoded_word = word.encode(encoding=self.encoding, errors='strict') + return Encoder.encodeLength(len(word)) + encoded_word + + @staticmethod + def encodeLength(length): + """ + Encode given length in mikrotik format. + + :param length: Integer < 268435456. + :returns: Encoded length. + """ + if length < 128: + ored_length = length + offset = -1 + elif length < 16384: + ored_length = length | 0x8000 + offset = -2 + elif length < 2097152: + ored_length = length | 0xC00000 + offset = -3 + elif length < 268435456: + ored_length = length | 0xE0000000 + offset = -4 + else: + raise ProtocolError('Unable to encode length of {}'.format(length)) + + return pack('!I', ored_length)[offset:] + + +class Decoder: + + @staticmethod + def determineLength(length): + """ + Given first read byte, determine how many more bytes + needs to be known in order to get fully encoded length. + + :param length: First read byte. + :return: How many bytes to read. + """ + integer = ord(length) + + #pylint: disable=no-else-return + if integer < 128: + return 0 + elif integer < 192: + return 1 + elif integer < 224: + return 2 + elif integer < 240: + return 3 + + raise ProtocolError('Unknown controll byte {}'.format(length)) + + @staticmethod + def decodeLength(length): + """ + Decode length based on given bytes. + + :param length: Bytes string to decode. + :return: Decoded length. + """ + bytes_length = len(length) + + if bytes_length < 2: + offset = b'\x00\x00\x00' + xor = 0 + elif bytes_length < 3: + offset = b'\x00\x00' + xor = 0x8000 + elif bytes_length < 4: + offset = b'\x00' + xor = 0xC00000 + elif bytes_length < 5: + offset = b'' + xor = 0xE0000000 + else: + raise ProtocolError('Unable to decode length of {}'.format(length)) + + decoded = unpack('!I', (offset + length))[0] + decoded ^= xor + return decoded + + +class ApiProtocol(Encoder, Decoder): + + def __init__(self, transport, encoding): + self.transport = transport + self.encoding = encoding + + @staticmethod + def log(direction_string, *sentence): + for word in sentence: + LOGGER.debug('{0} {1!r}'.format(direction_string, word)) + + LOGGER.debug('{0} EOS'.format(direction_string)) + + def writeSentence(self, cmd, *words): + """ + Write encoded sentence. + + :param cmd: Command word. + :param words: Aditional words. + """ + encoded = self.encodeSentence(cmd, *words) + self.log('<---', cmd, *words) + self.transport.write(encoded) + + def readSentence(self): + """ + Read every word untill empty word (NULL byte) is received. + + :return: Reply word, tuple with read words. + """ + sentence = tuple(word for word in iter(self.readWord, '')) + self.log('--->', *sentence) + reply_word, words = sentence[0], sentence[1:] + if reply_word == '!fatal': + self.transport.close() + raise FatalError(words[0]) + return reply_word, words + + def readWord(self): + byte = self.transport.read(1) + # Early return check for null byte + if byte == b'\x00': + return '' + to_read = self.determineLength(byte) + byte += self.transport.read(to_read) + length = self.decodeLength(byte) + word = self.transport.read(length) + return word.decode(encoding=self.encoding, errors='strict') + + def close(self): + self.transport.close() diff --git a/custom_components/mikrotik_router/librouteros_custom/query.py b/custom_components/mikrotik_router/librouteros_custom/query.py new file mode 100644 index 0000000..c3f9bf2 --- /dev/null +++ b/custom_components/mikrotik_router/librouteros_custom/query.py @@ -0,0 +1,64 @@ +# -*- coding: UTF-8 -*- +from itertools import chain +from .protocol import ( + cast_to_api, +) + + +class Key: + + def __init__(self, name): + self.name = name + + def __eq__(self, other): + yield '?={}={}'.format(self, cast_to_api(other)) + + def __ne__(self, other): + yield from self == other + yield '?#!' + + def __lt__(self, other): + yield '?<{}={}'.format(self, cast_to_api(other)) + + def __gt__(self, other): + yield '?>{}={}'.format(self, cast_to_api(other)) + + def __str__(self): + return str(self.name) + + +class Query: + + def __init__(self, path, keys, api): + self.path = path + self.keys = keys + self.api = api + self.query = tuple() + + def where(self, *args): + self.query = tuple(chain.from_iterable(args)) + return self + + def __iter__(self): + keys = ','.join(str(key) for key in self.keys) + keys = '=.proplist={}'.format(keys) + cmd = str(self.path.join('print')) + return iter(self.api.rawCmd(cmd, keys, *self.query)) + + +def And(left, right, *rest): + #pylint: disable=invalid-name + yield from left + yield from right + yield from chain.from_iterable(rest) + yield '?#&' + yield from ('?#&', ) * len(rest) + + +def Or(left, right, *rest): + #pylint: disable=invalid-name + yield from left + yield from right + yield from chain.from_iterable(rest) + yield '?#|' + yield from ('?#|', ) * len(rest) diff --git a/custom_components/mikrotik_router/mikrotikapi.py b/custom_components/mikrotik_router/mikrotikapi.py index 8639277..210afbb 100644 --- a/custom_components/mikrotik_router/mikrotikapi.py +++ b/custom_components/mikrotik_router/mikrotikapi.py @@ -10,7 +10,15 @@ from .const import ( DEFAULT_ENCODING, ) -import librouteros +import os +import sys +import importlib +MODULE_PATH = os.path.join(os.path.dirname(__file__), "librouteros_custom", "__init__.py") +MODULE_NAME = "librouteros_custom" +spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) +librouteros_custom = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = librouteros_custom +spec.loader.exec_module(librouteros_custom) _LOGGER = logging.getLogger(__name__) @@ -86,15 +94,15 @@ class MikrotikAPI: kwargs["ssl_wrapper"] = self._ssl_wrapper self.lock.acquire() try: - self._connection = librouteros.connect( + self._connection = librouteros_custom.connect( self._host, self._username, self._password, **kwargs ) except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ConnectionClosed, - librouteros.exceptions.ProtocolError, - librouteros.exceptions.FatalError, + librouteros_custom.exceptions.TrapError, + librouteros_custom.exceptions.MultiTrapError, + librouteros_custom.exceptions.ConnectionClosed, + librouteros_custom.exceptions.ProtocolError, + librouteros_custom.exceptions.FatalError, ssl.SSLError, BrokenPipeError, OSError, @@ -166,7 +174,7 @@ class MikrotikAPI: try: response = self._connection.path(path) _LOGGER.debug("API response (%s): %s", path, response) - except librouteros.exceptions.ConnectionClosed: + except librouteros_custom.exceptions.ConnectionClosed: if not self.connection_error_reported: _LOGGER.error("Mikrotik %s connection closed", self._host) self.connection_error_reported = True @@ -175,10 +183,10 @@ class MikrotikAPI: self.lock.release() return None except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ProtocolError, - librouteros.exceptions.FatalError, + librouteros_custom.exceptions.TrapError, + librouteros_custom.exceptions.MultiTrapError, + librouteros_custom.exceptions.ProtocolError, + librouteros_custom.exceptions.FatalError, ssl.SSLError, BrokenPipeError, OSError, @@ -202,7 +210,7 @@ class MikrotikAPI: try: tuple(response) - except librouteros.exceptions.ConnectionClosed as api_error: + except librouteros_custom.exceptions.ConnectionClosed as api_error: if not self.connection_error_reported: _LOGGER.error("Mikrotik %s error while path %s", self._host, api_error) self.connection_error_reported = True @@ -252,7 +260,7 @@ class MikrotikAPI: self.lock.acquire() try: response.update(**params) - except librouteros.exceptions.ConnectionClosed: + except librouteros_custom.exceptions.ConnectionClosed: if not self.connection_error_reported: _LOGGER.error("Mikrotik %s connection closed", self._host) self.connection_error_reported = True @@ -261,10 +269,10 @@ class MikrotikAPI: self.lock.release() return False except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ProtocolError, - librouteros.exceptions.FatalError, + librouteros_custom.exceptions.TrapError, + librouteros_custom.exceptions.MultiTrapError, + librouteros_custom.exceptions.ProtocolError, + librouteros_custom.exceptions.FatalError, ssl.SSLError, BrokenPipeError, OSError, @@ -326,7 +334,7 @@ class MikrotikAPI: try: run = response("run", **{".id": tmp[".id"]}) tuple(run) - except librouteros.exceptions.ConnectionClosed: + except librouteros_custom.exceptions.ConnectionClosed: if not self.connection_error_reported: _LOGGER.error("Mikrotik %s connection closed", self._host) self.connection_error_reported = True @@ -335,10 +343,10 @@ class MikrotikAPI: self.lock.release() return False except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ProtocolError, - librouteros.exceptions.FatalError, + librouteros_custom.exceptions.TrapError, + librouteros_custom.exceptions.MultiTrapError, + librouteros_custom.exceptions.ProtocolError, + librouteros_custom.exceptions.FatalError, ssl.SSLError, BrokenPipeError, OSError, @@ -395,7 +403,7 @@ class MikrotikAPI: _LOGGER.debug( "API response (%s): %s", "/interface/monitor-traffic", traffic ) - except librouteros.exceptions.ConnectionClosed: + except librouteros_custom.exceptions.ConnectionClosed: if not self.connection_error_reported: _LOGGER.error("Mikrotik %s connection closed", self._host) self.connection_error_reported = True @@ -404,10 +412,10 @@ class MikrotikAPI: self.lock.release() return None except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ProtocolError, - librouteros.exceptions.FatalError, + librouteros_custom.exceptions.TrapError, + librouteros_custom.exceptions.MultiTrapError, + librouteros_custom.exceptions.ProtocolError, + librouteros_custom.exceptions.FatalError, ssl.SSLError, BrokenPipeError, OSError, @@ -435,7 +443,7 @@ class MikrotikAPI: try: tuple(response) - except librouteros.exceptions.ConnectionClosed as api_error: + except librouteros_custom.exceptions.ConnectionClosed as api_error: if not self.connection_error_reported: _LOGGER.error( "Mikrotik %s error while get_traffic %s", self._host, api_error