reverted back to customized fork of librouteros

This commit is contained in:
tomaae 2020-03-18 09:33:36 +01:00
parent 1010a8aa3a
commit 0a58db40cd
8 changed files with 621 additions and 29 deletions

View file

@ -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)

View file

@ -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,
))

View file

@ -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()

View file

@ -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)

View file

@ -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}))

View file

@ -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()

View file

@ -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)

View file

@ -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