reverted to official librouteros #9

This commit is contained in:
tomaae 2020-03-16 18:33:51 +01:00
parent 25e90702d2
commit 3cf637d1ba
9 changed files with 31 additions and 621 deletions

View file

@ -1,60 +0,0 @@
# -*- 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

@ -1,136 +0,0 @@
# -*- 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

@ -1,37 +0,0 @@
# -*- 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

@ -1,52 +0,0 @@
# -*- 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

@ -1,26 +0,0 @@
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

@ -1,209 +0,0 @@
# -*- 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

@ -1,64 +0,0 @@
# -*- 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

@ -4,7 +4,9 @@
"config_flow": true, "config_flow": true,
"documentation": "https://github.com/tomaae/homeassistant-mikrotik_router", "documentation": "https://github.com/tomaae/homeassistant-mikrotik_router",
"dependencies": [], "dependencies": [],
"requirements": [], "requirements": [
"librouteros==3.0.0"
],
"codeowners": [ "codeowners": [
"@tomaae" "@tomaae"
] ]

View file

@ -2,10 +2,7 @@
import ssl import ssl
import logging import logging
import os
import sys
import time import time
import importlib
from threading import Lock from threading import Lock
from .exceptions import ApiEntryNotFound from .exceptions import ApiEntryNotFound
from .const import ( from .const import (
@ -13,12 +10,7 @@ from .const import (
DEFAULT_ENCODING, DEFAULT_ENCODING,
) )
MODULE_PATH = os.path.join(os.path.dirname(__file__), "librouteros_custom", "__init__.py") import librouteros
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__) _LOGGER = logging.getLogger(__name__)
@ -84,13 +76,13 @@ class MikrotikAPI:
kwargs["ssl_wrapper"] = self._ssl_wrapper kwargs["ssl_wrapper"] = self._ssl_wrapper
self.lock.acquire() self.lock.acquire()
try: try:
self._connection = librouteros_custom.connect(self._host, self._username, self._password, **kwargs) self._connection = librouteros.connect(self._host, self._username, self._password, **kwargs)
except ( except (
librouteros_custom.exceptions.TrapError, librouteros.exceptions.TrapError,
librouteros_custom.exceptions.MultiTrapError, librouteros.exceptions.MultiTrapError,
librouteros_custom.exceptions.ConnectionClosed, librouteros.exceptions.ConnectionClosed,
librouteros_custom.exceptions.ProtocolError, librouteros.exceptions.ProtocolError,
librouteros_custom.exceptions.FatalError, librouteros.exceptions.FatalError,
ssl.SSLError, ssl.SSLError,
BrokenPipeError, BrokenPipeError,
OSError OSError
@ -147,16 +139,16 @@ class MikrotikAPI:
try: try:
response = self._connection.path(path) response = self._connection.path(path)
_LOGGER.debug("API response (%s): %s", path, response) _LOGGER.debug("API response (%s): %s", path, response)
except librouteros_custom.exceptions.ConnectionClosed: except librouteros.exceptions.ConnectionClosed:
_LOGGER.error("Mikrotik %s connection closed", self._host) _LOGGER.error("Mikrotik %s connection closed", self._host)
self.disconnect() self.disconnect()
self.lock.release() self.lock.release()
return None return None
except ( except (
librouteros_custom.exceptions.TrapError, librouteros.exceptions.TrapError,
librouteros_custom.exceptions.MultiTrapError, librouteros.exceptions.MultiTrapError,
librouteros_custom.exceptions.ProtocolError, librouteros.exceptions.ProtocolError,
librouteros_custom.exceptions.FatalError, librouteros.exceptions.FatalError,
ssl.SSLError, ssl.SSLError,
BrokenPipeError, BrokenPipeError,
OSError, OSError,
@ -174,7 +166,7 @@ class MikrotikAPI:
try: try:
tuple(response) tuple(response)
except librouteros_custom.exceptions.ConnectionClosed as api_error: except librouteros.exceptions.ConnectionClosed as api_error:
_LOGGER.error("Mikrotik %s error while path %s", self._host, api_error) _LOGGER.error("Mikrotik %s error while path %s", self._host, api_error)
self.disconnect() self.disconnect()
self.lock.release() self.lock.release()
@ -221,16 +213,16 @@ class MikrotikAPI:
self.lock.acquire() self.lock.acquire()
try: try:
response.update(**params) response.update(**params)
except librouteros_custom.exceptions.ConnectionClosed: except librouteros.exceptions.ConnectionClosed:
_LOGGER.error("Mikrotik %s connection closed", self._host) _LOGGER.error("Mikrotik %s connection closed", self._host)
self.disconnect() self.disconnect()
self.lock.release() self.lock.release()
return False return False
except ( except (
librouteros_custom.exceptions.TrapError, librouteros.exceptions.TrapError,
librouteros_custom.exceptions.MultiTrapError, librouteros.exceptions.MultiTrapError,
librouteros_custom.exceptions.ProtocolError, librouteros.exceptions.ProtocolError,
librouteros_custom.exceptions.FatalError, librouteros.exceptions.FatalError,
ssl.SSLError, ssl.SSLError,
BrokenPipeError, BrokenPipeError,
OSError, OSError,
@ -282,16 +274,16 @@ class MikrotikAPI:
try: try:
run = response('run', **{'.id': tmp['.id']}) run = response('run', **{'.id': tmp['.id']})
tuple(run) tuple(run)
except librouteros_custom.exceptions.ConnectionClosed: except librouteros.exceptions.ConnectionClosed:
_LOGGER.error("Mikrotik %s connection closed", self._host) _LOGGER.error("Mikrotik %s connection closed", self._host)
self.disconnect() self.disconnect()
self.lock.release() self.lock.release()
return False return False
except ( except (
librouteros_custom.exceptions.TrapError, librouteros.exceptions.TrapError,
librouteros_custom.exceptions.MultiTrapError, librouteros.exceptions.MultiTrapError,
librouteros_custom.exceptions.ProtocolError, librouteros.exceptions.ProtocolError,
librouteros_custom.exceptions.FatalError, librouteros.exceptions.FatalError,
ssl.SSLError, ssl.SSLError,
BrokenPipeError, BrokenPipeError,
OSError, OSError,
@ -336,16 +328,16 @@ class MikrotikAPI:
try: try:
traffic = response('monitor-traffic', **args) traffic = response('monitor-traffic', **args)
_LOGGER.debug("API response (%s): %s", "/interface/monitor-traffic", traffic) _LOGGER.debug("API response (%s): %s", "/interface/monitor-traffic", traffic)
except librouteros_custom.exceptions.ConnectionClosed: except librouteros.exceptions.ConnectionClosed:
_LOGGER.error("Mikrotik %s connection closed", self._host) _LOGGER.error("Mikrotik %s connection closed", self._host)
self.disconnect() self.disconnect()
self.lock.release() self.lock.release()
return None return None
except ( except (
librouteros_custom.exceptions.TrapError, librouteros.exceptions.TrapError,
librouteros_custom.exceptions.MultiTrapError, librouteros.exceptions.MultiTrapError,
librouteros_custom.exceptions.ProtocolError, librouteros.exceptions.ProtocolError,
librouteros_custom.exceptions.FatalError, librouteros.exceptions.FatalError,
ssl.SSLError, ssl.SSLError,
BrokenPipeError, BrokenPipeError,
OSError, OSError,