├── .gitignore ├── MANIFEST.in ├── README.rst ├── mikrotik ├── __init__.py └── mikrotik.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | dist/ 4 | build/ 5 | *.egg-info/ 6 | env/ 7 | .idea/ 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | Mikrotik API 3 | ==== 4 | 5 | .. image:: https://drone.io/github.com/annttu/Mikrotik/status.png 6 | 7 | Mikrotik-API is a python client library for Mikrotik API. 8 | 9 | Currently library supports python >= 2.7 10 | 11 | Usage 12 | ----- 13 | 14 | Example: 15 | 16 | .. code-block:: python 17 | 18 | import mikrotik 19 | m = mikrotik.Mikrotik("10.0.0.1") 20 | m.login("user", "pass") 21 | m.run("/ip/address/print") 22 | 23 | 24 | 25 | Installation 26 | -------- 27 | .. code-block:: python 28 | 29 | install --extra-index-url http://code.annttu.fi/ mikrotik 30 | 31 | 32 | License 33 | ------- 34 | 35 | The MIT License (MIT) 36 | 37 | Copyright (c) 2014 Antti Jaakkola 38 | 39 | Permission is hereby granted, free of charge, to any person obtaining a copy 40 | of this software and associated documentation files (the "Software"), to deal 41 | in the Software without restriction, including without limitation the rights 42 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 43 | copies of the Software, and to permit persons to whom the Software is 44 | furnished to do so, subject to the following conditions: 45 | 46 | The above copyright notice and this permission notice shall be included in 47 | all copies or substantial portions of the Software. 48 | 49 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 50 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 51 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 52 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 53 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 54 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 55 | THE SOFTWARE. 56 | -------------------------------------------------------------------------------- /mikrotik/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | if sys.version_info<(3,0,0): 3 | from mikrotik import * 4 | else: 5 | from mikrotik.mikrotik import * 6 | -------------------------------------------------------------------------------- /mikrotik/mikrotik.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # encoding: utf-8 3 | from _socket import SHUT_WR 4 | 5 | import logging 6 | import socket 7 | import struct 8 | from hashlib import md5 9 | import binascii 10 | import ssl 11 | 12 | 13 | logger = logging.getLogger("MikrotikAPI") 14 | 15 | 16 | class MikrotikAPIError(Exception): 17 | pass 18 | 19 | 20 | class MikrotikAPIErrorCategory: 21 | 22 | MISSING = 0 23 | ARGUMENT_VALUE = 1 24 | INTERRUPTED = 2 25 | SCRIPT_FAILURE = 3 26 | GENERAL_FAILURE = 4 27 | API_FAILURE = 5 28 | TTY_FAILURE = 6 29 | RETURN_VALUE = 7 30 | 31 | 32 | def pack_length(length): 33 | """ 34 | Pack api request length. 35 | http://wiki.mikrotik.com/wiki/Manual:API#Protocol 36 | """ 37 | if length < 0x80: 38 | return struct.pack("!B", length) 39 | elif length <= 0x3FFF: 40 | length = length | 0x8000 41 | return struct.pack("!BB", (length >> 8) & 0xFF, length & 0xFF) 42 | elif length <= 0x1FFFFF: 43 | length = length | 0xC00000 44 | return struct.pack("!BBB", length >> 16, (length & 0xFFFF) >> 8, length & 0xFF) 45 | elif length <= 0xFFFFFFF: 46 | length = length | 0xE0000000 47 | return struct.pack("!BBBB", length >> 24, (length & 0xFFFFFF) >> 16, (length & 0xFFFF) >> 8, length & 0xFF) 48 | else: 49 | raise MikrotikAPIError("Too long command!") 50 | 51 | 52 | def unpack_length(length): 53 | """ 54 | Unpack api request length. 55 | :param length: length string to unpack 56 | :return: length as integer 57 | """ 58 | if len(length) == 1: 59 | return ord(length) 60 | elif len(length) == 2: 61 | c = ord(length[0]) & ~0xC0 62 | c <<= 8 63 | return c + ord(length[1]) 64 | elif len(length) == 3: 65 | c = ord(length[0]) & ~0xE0 66 | c <<= 8 67 | c += ord(length[1]) 68 | c <<= 8 69 | return c + ord(length[2]) 70 | elif len(length) == 4 and (ord(length[0]) & 0xF0) == 0xE0: 71 | c = ord(length[0]) & ~0xF0 72 | c <<= 8 73 | c += ord(length[1]) 74 | c <<= 8 75 | c += ord(length[2]) 76 | c <<= 8 77 | return c + ord(length[3]) 78 | elif len(length) == 4 and (ord(length[0]) & 0x8F) == 0xF0: 79 | c = ord(length[1]) 80 | c <<= 8 81 | c += ord(length[2]) 82 | c <<= 8 83 | c += ord(length[3]) 84 | c <<= 8 85 | return c + ord(length[4]) 86 | raise MikrotikAPIError("Invalid message length %s!" % length) 87 | 88 | 89 | class MikrotikAPIResponseTypes: 90 | STATUS = 1 91 | ERROR = 2 92 | DATA = 3 93 | 94 | 95 | class MikrotikApiResponse(object): 96 | def __init__(self, status, type, attributes=None, error=None): 97 | self.status = status 98 | self.type = type 99 | self.attributes = attributes 100 | self.error = error 101 | 102 | def __str__(self): 103 | return "!%s %s %s" % (self.status, ' '.join(["%s=%s" % (k, v) for k, v in self.attributes.items()]), 104 | ' '.join(self.error)) 105 | 106 | 107 | class MikrotikAPIRequest(object): 108 | def __init__(self, command, attributes=None, api_attributes=None, queries=None): 109 | """ 110 | Generate request for Mikrotik RouterOS API. 111 | """ 112 | if not command.startswith('/'): 113 | raise MikrotikAPIError("Command should start with /") 114 | self.command = command 115 | if attributes: 116 | self.attributes = attributes 117 | else: 118 | self.attributes = {} 119 | if api_attributes: 120 | self.api_attributes = api_attributes 121 | else: 122 | self.api_attributes = {} 123 | if queries: 124 | self.queries = queries 125 | else: 126 | self.queries = {} 127 | 128 | def get_request(self): 129 | request = [] 130 | 131 | request.append(pack_length(len(self.command))) 132 | request.append(self.command.encode("utf-8")) 133 | 134 | for attribute, value in self.attributes.items(): 135 | attrib = "=%s=%s" % (attribute, value) 136 | request.append(pack_length(len(attrib))) 137 | request.append(attrib.encode("utf-8")) 138 | 139 | for attribute, value in self.api_attributes.items(): 140 | attrib = ".%s=%s" % (attribute, value) 141 | request.append(pack_length(len(attrib))) 142 | request.append(attrib.encode("utf-8")) 143 | 144 | # TODO: complete query parsing 145 | for key, value in self.queries.items(): 146 | if value: 147 | query = "?%s=%s" % (key, value) 148 | else: 149 | query = "?%s" % (key,) 150 | request.append(pack_length(len(query))) 151 | request.append(query.encode("utf-8")) 152 | 153 | request.append(pack_length(0)) 154 | return b''.join(request) 155 | 156 | 157 | class Mikrotik(object): 158 | def __init__(self, address, ssl=True, port=None, verify=True): 159 | self._address = address 160 | self._verify = verify 161 | if port: 162 | self._port = port 163 | if ssl: 164 | self._port = 8729 165 | else: 166 | self._port = 8728 167 | self._ssl = ssl 168 | self.connect() 169 | 170 | def connect(self): 171 | self._socket = socket.socket() 172 | if self._ssl: 173 | context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 174 | context.options |= ssl.OP_NO_SSLv2 175 | context.options |= ssl.OP_NO_SSLv3 176 | if self._verify: 177 | context.verify_mode = ssl.CERT_REQUIRED 178 | else: 179 | context.verify_mode = ssl.CERT_NONE 180 | self._socket = context.wrap_socket(self._socket) 181 | self._socket.connect((self._address, self._port)) 182 | 183 | def _send(self, data): 184 | logger.debug("Sending %s" % data) 185 | self._socket.send(data) 186 | 187 | def _recv(self): 188 | responses = b'' 189 | while True: 190 | responses += self._socket.recv(2048) 191 | logger.debug("Got %s from API" % (responses,)) 192 | if b'!done' not in responses or responses[-1] != 0: 193 | # Next iteration needed 194 | continue 195 | break 196 | 197 | if len(responses) < 2: 198 | raise MikrotikAPIError("Invalid response from API: too short message") 199 | 200 | return_values = [] 201 | 202 | for response in responses.split(b'\x00')[:-1]: 203 | start = 0 204 | response = response.decode("utf-8", 'ignore') 205 | f = response.find("!") 206 | length = unpack_length(response[:f]) 207 | response = response[f:] 208 | status = response[1:length] 209 | if status not in ['done', 'trap', 'fatal', 're']: 210 | raise MikrotikAPIError("Invalid response from API: invalid status %s" % status) 211 | if status == 'done': 212 | _type = MikrotikAPIResponseTypes.STATUS 213 | elif status == 're': 214 | _type = MikrotikAPIResponseTypes.DATA 215 | elif status in ['trap', 'fatal']: 216 | _type = MikrotikAPIResponseTypes.ERROR 217 | else: 218 | raise MikrotikAPIError("Got unknown error from API: %s" % response[length+1:]) 219 | start += length 220 | length_length = 0 221 | data = {} 222 | errors = [] 223 | while start < len(response): 224 | if (ord(response[start]) & 0x80) == 0x00: 225 | length = unpack_length(response[start]) 226 | length_length = 1 227 | elif (ord(response[start]) & 0xC0) == 0x80: 228 | length = unpack_length(response[start:start+1]) 229 | length_length = 2 230 | elif (ord(response[start]) & 0xE0) == 0xC0: 231 | length = unpack_length(response[start:start+2]) 232 | length_length = 3 233 | elif (ord(response[start]) & 0xF0) == 0xE0: 234 | length = unpack_length(response[start:start+3]) 235 | length_length = 4 236 | elif (ord(response[start]) & 0xF8) == 0xF0: 237 | length = unpack_length(response[start:start+4]) 238 | length_length = 5 239 | start += length_length 240 | message = response[start:start+length] 241 | if message.startswith('='): 242 | if message.startswith("=message="): 243 | errors.append(message[9:]) 244 | else: 245 | if '=' not in message[1:]: 246 | raise MikrotikAPIError("Got unknown response from API: %s after %s, (%s)" % (message, response[:start], data)) 247 | (k, v) = message[1:].split("=", 1) 248 | data.update({k: v}) 249 | elif message.startswith("debug-info=") or message.startswith("ht-supported-mcs="): 250 | # This is fix for Mikrotik bug which makes debug-info and ht-supported-mcs= fields don't have proper 251 | # length. 252 | first = response[start:].find("=") 253 | next = response[start+first+1:].find("=") 254 | if next > 0: 255 | length = next + first 256 | # Check, if next length is 61 257 | if response[start+length+2] == "=": 258 | length += 1 259 | logger.debug("Detected debug-info= field") 260 | elif not message.startswith("!"): 261 | raise MikrotikAPIError("Unknown message '%s'" % (message,)) 262 | start += length 263 | return_values.append(MikrotikApiResponse(status=status, type=_type, error=errors, attributes=data)) 264 | return return_values 265 | 266 | def login(self, username, password): 267 | try: 268 | return self.login_post_45(username=username, password=password) 269 | except MikrotikAPIError: 270 | return self.login_pre_45(username=username, password=password) 271 | 272 | 273 | def login_post_45(self, username, password): 274 | r = MikrotikAPIRequest(command="/login", attributes={"name": username, "password": password}) 275 | self._send(r.get_request()) 276 | response = self._recv()[0] 277 | if response.status == "done": 278 | return 279 | raise MikrotikAPIError("Cannot log in, %s!" % ' '.join(response.error)) 280 | 281 | def login_pre_45(self, username, password): 282 | r = MikrotikAPIRequest(command="/login") 283 | self._send(r.get_request()) 284 | response = self._recv()[0] 285 | if 'ret' in response.attributes.keys(): 286 | value = binascii.unhexlify(response.attributes['ret']) 287 | md = md5() 288 | md.update('\x00'.encode("utf-8")) 289 | md.update(password.encode("utf-8")) 290 | md.update(value) 291 | r = MikrotikAPIRequest(command="/login", attributes={'name': username, 'response': "00" + md.hexdigest()}) 292 | self._send(r.get_request()) 293 | response = self._recv() 294 | if response[0].status == "trap": 295 | raise MikrotikAPIError("Cannot log in, %s!" % ' '.join(response[0].error)) 296 | return 297 | raise MikrotikAPIError("Cannot log in!") 298 | 299 | def run(self, *args, **kwargs): 300 | r = MikrotikAPIRequest(*args, **kwargs) 301 | self._send(r.get_request()) 302 | return self._recv() 303 | 304 | def disconnect(self): 305 | self._socket.shutdown(SHUT_WR) 306 | self._socket.close() 307 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [info] 2 | tag_build = dev 3 | 4 | [upload] 5 | dry-run = 1 6 | 7 | [metadata] 8 | description-file = README.rst 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | from setuptools import setup, find_packages 3 | 4 | 5 | # Get the long description from the relevant file 6 | with open('README.rst', encoding='utf-8') as f: 7 | long_description = f.read() 8 | 9 | 10 | setup(name='mikrotik', 11 | version='0.1', 12 | description="Client library for Mikrotik API", 13 | long_description=long_description, 14 | classifiers=[], 15 | keywords='mikrotik', 16 | author='Antti Jaakkola', 17 | author_email='mikrotik@annttu.fi', 18 | url='https://github.com/annttu/Mikrotik', 19 | license='MIT', 20 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 21 | include_package_data=True, 22 | zip_safe=True, 23 | install_requires=[], 24 | extras_require={ 25 | 'test': ['pytest'] 26 | } 27 | ) 28 | --------------------------------------------------------------------------------