├── setup.py ├── ros_api ├── __init__.py ├── _log.py └── api.py ├── .gitignore ├── .bumpversion.cfg ├── pyproject.toml ├── LICENSE └── README.md /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /ros_api/__init__.py: -------------------------------------------------------------------------------- 1 | from ros_api.api import Api 2 | __version__ = "1.1.0" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | venv 3 | scraps 4 | .idea/ 5 | .gitignore 6 | dist/ 7 | *.egg-info/ 8 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | 8 | [bumpversion:file:ros_api/__init__.py] 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.1.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "laiarturs-ros_api" 7 | version = "1.1.0" 8 | description = "Connect to and use API interface of MikroTik RouterOS" 9 | readme = "README.md" 10 | authors = [{ name = "Arturs Laizans" }] 11 | license = { file = "LICENSE" } 12 | classifiers = [ 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Development Status :: 4 - Beta", 17 | "Topic :: System :: Networking", 18 | ] 19 | keywords = ["MikroTik", "RouterOS", "router", "API"] 20 | dependencies = [] 21 | requires-python = ">=3.4" 22 | 23 | [project.optional-dependencies] 24 | dev = ["bump2version"] 25 | 26 | [project.urls] 27 | Homepage = "https://github.com/DEssMALA/RouterOS_API" 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ros_api/_log.py: -------------------------------------------------------------------------------- 1 | # Author: Arturs Laizans 2 | # Package for displaying and saving verbose log 3 | 4 | import logging 5 | 6 | 7 | class Log: 8 | 9 | # For initialization, class Log takes 3 arguments: path, logic, file_mode. 10 | # path: 11 | # - False - don't do logging. It won't save anything to file and won't print anything to stdout. 12 | # - True - will print verbose output to stdout. 13 | # - string - will save the verbose output to file named as this string. 14 | # logic: 15 | # - 'OR' - if the path is a string, only saves verbose to file; 16 | # - 'AND' - if the path is string, prints verbose output to stdout and saves to file. 17 | # file_mode: 18 | # - 'a' - appends log to existing file 19 | # - 'w' - creates a new file for logging, if a file with such name already exists, it will be overwritten. 20 | 21 | def __init__(self, path, logic, file_mode): 22 | 23 | # If logging to file is needed, configure it 24 | if path is not True and type(path) == str: 25 | logging.basicConfig(filename=path, filemode=file_mode, 26 | format='%(asctime)s - %(message)s', level=logging.DEBUG) 27 | 28 | # Define different log actions that can be used 29 | def nothing(message): 30 | pass 31 | 32 | def to_file(message): 33 | logging.debug(message) 34 | 35 | def to_stdout(message): 36 | print(message) 37 | 38 | def both(message): 39 | print(message) 40 | logging.debug(message) 41 | 42 | # Set appropriate action depending on path and logic values 43 | if not path: 44 | self.func = nothing 45 | 46 | elif path is True: 47 | self.func = to_stdout 48 | 49 | elif path is not True and type(path) == str and logic == 'OR': 50 | self.func = to_file 51 | 52 | elif path is not True and type(path) == str and logic == 'AND': 53 | self.func = both 54 | else: 55 | self.func = to_stdout 56 | 57 | def __call__(self, message): 58 | self.func(message) 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RouterOS API 2 | 3 | Python API for MikroTik RouterOS. Simple and easy to use. 4 | 5 | > **WARNING** for old users: 6 | > 7 | > Project has changes it's structure and import signature. 8 | 9 | #### Features: 10 | * Easy to use; 11 | * Standard RouterOS API syntax; 12 | * SSL; 13 | * Verbose. 14 | 15 | Find this project on [PyPI.org](https://pypi.org/project/laiarturs-ros-api/). 16 | 17 | ## Installation 18 | 19 | ```sh 20 | python -m pip install laiarturs-ros-api 21 | ``` 22 | 23 | ## Usage: 24 | 25 | #### Default configuration: 26 | 27 | *Python code:* 28 | ```python 29 | import ros_api 30 | 31 | router = ros_api.Api('192.168.88.1') 32 | r = router.talk('/system/identity/print') 33 | print(r) 34 | ``` 35 | 36 | *Output:* 37 | ``` 38 | [{'name': 'MikroTik'}] 39 | ``` 40 | 41 | #### Username, password, port: 42 | 43 | *Python code:* 44 | ```python 45 | import ros_api 46 | 47 | router = ros_api.Api('10.21.0.100', user='Bob', password='St4ong0nE', port=15811) 48 | r = router.talk('/ip/address/print') 49 | print(r) 50 | ``` 51 | 52 | *Output:* 53 | ``` 54 | [{'.id': '*5', 'address': '10.21.0.100/24', 'network': '10.21.0.0','interface': 'ether1', 55 | 'actual-interface': 'ether1', 'invalid': 'false', 'dynamic': 'false', 'disabled': 'false'}] 56 | 57 | ``` 58 | 59 | #### SSL and verbose: 60 | 61 | On RouterOS router create **certificate** and assign it to **api-ssl** service. 62 | 63 | *RouterOS:* 64 | ``` 65 | /certificate 66 | add name=ca-template common-name=myCa key-usage=key-cert-sign,crl-sign 67 | add name=server-template common-name=server 68 | sign ca-template ca-crl-host=10.21.0.100 name=myCa 69 | sign server-template ca=myCa name=server 70 | 71 | /ip service 72 | set [find name=api-ssl] certificate=server 73 | ``` 74 | More info: [MikroTik Wiki](https://wiki.mikrotik.com/wiki/Manual:Create_Certificates). 75 | 76 | *Python code:* 77 | ```python 78 | import ros_api 79 | 80 | router = ros_api.Api('10.21.0.100', user='SysAdmin', password='Meeseeks', verbose=True, use_ssl=True) 81 | r = router.talk('/interface/wireless/enable\n=numbers=0') 82 | print(r) 83 | ``` 84 | 85 | *Output:* 86 | ``` 87 | >>> /login 88 | >>> =name=SysAdmin 89 | >>> =password=Meeseeks 90 | 91 | <<< !done 92 | 93 | >>> /interface/wireless/enable 94 | >>> =numbers=0 95 | 96 | <<< !done 97 | 98 | [] 99 | ``` 100 | 101 | ## How it works: 102 | Python3 module *routeros_api.py* contains class *Api*. 103 | #### \_\_init__() 104 | By initialising this class it creates socket, connects and logs in. 105 | *Api* class *\_\_init__()* arguments: 106 | 107 | Argument | Description 108 | ----------|------------ 109 | `address` | `str` of IP address or host of RouterOS router on which it can be reached. 110 | `user` | `str` of username on router, *default='admin'*. 111 | `password`| `str` of password of user on router, *default=''*. 112 | `use_ssl` | `bool` whether to use SSL, *default=False*. 113 | `port` | `int` on which port to connect to router, *default=8728*, *ssl default=8729*. 114 | `verbose` | `bool` whether to print conversation with router, *default=False*. 115 | `context` | `ssl instance` for creating ssl connection, default is created, but it can be adjusted. 116 | `timeout` | `float` in seconds to set timeout on socket blocking operations, *default=None*. 117 | 118 | *Python code:* 119 | ```python 120 | router = Api(address='192.168.10.1', user='Juri', password='L0vE$aun@', 121 | use_ssl=True, port=8730, verbose=False, context=ctx, timeout=1) 122 | ``` 123 | 124 | #### talk() 125 | 126 | To send commands to router use *talk()* method of *Api* class. *talk()* take one argument - message: 127 | 128 | Argument | Description 129 | ----------|------------ 130 | `message` | `str`, `tuple` or `list` of strings or tuples. It is possible to send multiple commands bundled in a list. 131 | 132 | *Python code:* 133 | ```python 134 | message = [('/system/note/set', '=note=Multi line\nnote for the Router!'), '/system/note/print'] 135 | r = router.talk(message) 136 | print(r) 137 | ``` 138 | *Output:* 139 | ``` 140 | [[], [{'show-at-login': 'true', 'note': 'Multi line\nnote for the Router!'}]] 141 | ``` 142 | 143 | If property values you want to send to router contains spaces or linebreaks, sentence must be divided in words and then 144 | passed to talk() as `tuple`. Otherwise you can send sentences as strings and it will be divided in words where there is 145 | space or linebreak. 146 | 147 | Method *talk()* returns `list` containing replies from router. In this case there are two replies, because *message* 148 | contained two sentences. Actions like *set*, *add*, *enable* etc. usually returns empty list, however, *print*, *monitor* 149 | and others returns `list` with `dict` inside containing reply from router. 150 | 151 | Messages use RouterOS API syntax. More info: [MikroTik Wiki](https://wiki.mikrotik.com/wiki/Manual:API). 152 | -------------------------------------------------------------------------------- /ros_api/api.py: -------------------------------------------------------------------------------- 1 | # Author: Arturs Laizans 2 | 3 | import socket 4 | import ssl 5 | import hashlib 6 | import binascii 7 | 8 | from . import _log 9 | 10 | # Constants - Define defaults 11 | PORT = 8728 12 | SSL_PORT = 8729 13 | 14 | USER = 'admin' 15 | PASSWORD = '' 16 | 17 | USE_SSL = False 18 | 19 | VERBOSE = False # Whether to print API conversation width the router. Useful for debugging 20 | VERBOSE_LOGIC = 'OR' # Whether to print and save verbose log to file. AND - print and save, OR - do only one. 21 | VERBOSE_FILE_MODE = 'w' # Weather to create new file ('w') for log or append to old one ('a'). 22 | 23 | TIMEOUT = None # Whether to use timeout for socket connection 24 | 25 | CONTEXT = ssl.create_default_context() # It is possible to predefine context for SSL socket 26 | CONTEXT.check_hostname = False 27 | CONTEXT.verify_mode = ssl.CERT_NONE 28 | 29 | 30 | class LoginError(Exception): 31 | pass 32 | 33 | 34 | class WordTooLong(Exception): 35 | pass 36 | 37 | 38 | class CreateSocketError(Exception): 39 | pass 40 | 41 | 42 | class RouterOSTrapError(Exception): 43 | pass 44 | 45 | 46 | class Api: 47 | 48 | def __init__(self, address, user=USER, password=PASSWORD, use_ssl=USE_SSL, port=False, 49 | verbose=VERBOSE, context=CONTEXT, timeout=TIMEOUT): 50 | 51 | self.address = address 52 | self.user = user 53 | self.password = password 54 | self.use_ssl = use_ssl 55 | self.port = port 56 | self.verbose = verbose 57 | self.context = context 58 | self.timeout = timeout 59 | 60 | # Port setting logic 61 | if port: 62 | self.port = port 63 | elif use_ssl: 64 | self.port = SSL_PORT 65 | else: 66 | self.port = PORT 67 | 68 | # Create Log instance to save or print verbose logs 69 | self.log = _log.Log(verbose, VERBOSE_LOGIC, VERBOSE_FILE_MODE) 70 | self.log('') 71 | self.log('#-----------------------------------------------#') 72 | self.log('API IP - {}, USER - {}'.format(address, user)) 73 | self.sock = None 74 | self.connection = None 75 | self.open_socket() 76 | self.login() 77 | self.log('Instance of Api created') 78 | self.is_alive() 79 | 80 | # Open socket connection with router and wrap with SSL if needed. 81 | def open_socket(self): 82 | 83 | for res in socket.getaddrinfo(self.address, self.port, socket.AF_UNSPEC, socket.SOCK_STREAM): 84 | af, socktype, proto, canonname, sa = res 85 | 86 | self.sock = socket.socket(af, socket.SOCK_STREAM) 87 | self.sock.settimeout(self.timeout) 88 | 89 | try: 90 | # Trying to connect to RouterOS, error can occur if IP address is not reachable, or API is blocked in 91 | # RouterOS firewall or ip services, or port is wrong. 92 | self.connection = self.sock.connect(sa) 93 | 94 | except OSError: 95 | raise CreateSocketError('Error: API failed to connect to socket. Host: {}, port: {}.'.format(self.address, 96 | self.port)) 97 | 98 | if self.use_ssl: 99 | self.sock = self.context.wrap_socket(self.sock) 100 | 101 | self.log('API socket connection opened.') 102 | 103 | # Login API connection into RouterOS 104 | def login(self): 105 | 106 | def reply_has_error(reply): 107 | # Check if reply contains login error 108 | if len(reply[0]) == 2 and reply[0][0] == '!trap': 109 | return True 110 | else: 111 | return False 112 | 113 | def process_old_login(reply): 114 | # RouterOS uses old API login method, code continues with old method 115 | self.log('Using old login process.') 116 | md5 = hashlib.md5(('\x00' + self.password).encode('utf-8')) 117 | md5.update(binascii.unhexlify(reply[0][1][5:])) 118 | sentence = ['/login', '=name=' + self.user, '=response=00' 119 | + binascii.hexlify(md5.digest()).decode('utf-8')] 120 | self.log('Logged in successfully!') 121 | reply = self.communicate(sentence) 122 | return check_reply(reply) 123 | 124 | def check_reply(reply): 125 | if len(reply[0]) == 1 and reply[0][0] == '!done': 126 | # If login process was successful 127 | self.log('Logged in successfully!') 128 | return reply 129 | elif reply_has_error(reply): 130 | self.log(f'Error in login process: {reply[0][1]}') 131 | raise LoginError(reply) 132 | elif len(reply[0]) == 2 and reply[0][1][0:5] == '=ret=': 133 | return process_old_login(reply) 134 | else: 135 | raise LoginError(f'Unexpected reply to login: {reply}') 136 | 137 | sentence = ['/login', '=name=' + self.user, '=password=' + self.password] 138 | reply = self.communicate(sentence) 139 | return check_reply(reply) 140 | 141 | # Sending data to router and expecting something back 142 | def communicate(self, sentence_to_send): 143 | 144 | # There is specific way of sending word length in RouterOS API. 145 | # See RouterOS API Wiki for more info. 146 | def send_length(w): 147 | length_to_send = len(w) 148 | if length_to_send < 0x80: 149 | num_of_bytes = 1 # For words smaller than 128 150 | elif length_to_send < 0x4000: 151 | length_to_send += 0x8000 152 | num_of_bytes = 2 # For words smaller than 16384 153 | elif length_to_send < 0x200000: 154 | length_to_send += 0xC00000 155 | num_of_bytes = 3 # For words smaller than 2097152 156 | elif length_to_send < 0x10000000: 157 | length_to_send += 0xE0000000 158 | num_of_bytes = 4 # For words smaller than 268435456 159 | elif length_to_send < 0x100000000: 160 | num_of_bytes = 4 # For words smaller than 4294967296 161 | self.sock.sendall(b'\xF0') 162 | else: 163 | raise WordTooLong('Word is too long. Max length of word is 4294967295.') 164 | self.sock.sendall(length_to_send.to_bytes(num_of_bytes, byteorder='big')) 165 | 166 | # Actually I haven't successfully sent words larger than approx. 65520. 167 | # Probably it is some RouterOS limitation of 2^16. 168 | 169 | # The same logic applies for receiving word length from RouterOS side. 170 | # See RouterOS API Wiki for more info. 171 | def receive_length(): 172 | r = self.sock.recv(1) # Receive the first byte of word length 173 | 174 | # If the first byte of word is smaller than 80 (base 16), 175 | # then we already received the whole length and can return it. 176 | # Otherwise if it is larger, then word size is encoded in multiple bytes and we must receive them all to 177 | # get the whole word size. 178 | 179 | if r < b'\x80': 180 | r = int.from_bytes(r, byteorder='big') 181 | elif r < b'\xc0': 182 | r += self.sock.recv(1) 183 | r = int.from_bytes(r, byteorder='big') 184 | r -= 0x8000 185 | elif r < b'\xe0': 186 | r += self.sock.recv(2) 187 | r = int.from_bytes(r, byteorder='big') 188 | r -= 0xC00000 189 | elif r < b'\xf0': 190 | r += self.sock.recv(3) 191 | r = int.from_bytes(r, byteorder='big') 192 | r -= 0xE0000000 193 | elif r == b'\xf0': 194 | r = self.sock.recv(4) 195 | r = int.from_bytes(r, byteorder='big') 196 | 197 | return r 198 | 199 | def read_sentence(): 200 | rcv_sentence = [] # Words will be appended here 201 | rcv_length = receive_length() # Get the size of the word 202 | 203 | while rcv_length != 0: 204 | received = b'' 205 | while rcv_length > len(received): 206 | rec = self.sock.recv(rcv_length - len(received)) 207 | if rec == b'': 208 | raise RuntimeError('socket connection broken') 209 | received += rec 210 | received = received.decode('utf-8', 'backslashreplace') 211 | self.log('<<< {}'.format(received)) 212 | rcv_sentence.append(received) 213 | rcv_length = receive_length() # Get the size of the next word 214 | self.log('') 215 | return rcv_sentence 216 | 217 | # Sending part of conversation 218 | 219 | # Each word must be sent separately. 220 | # First, length of the word must be sent, 221 | # Then, the word itself. 222 | for word in sentence_to_send: 223 | send_length(word) 224 | self.sock.sendall(word.encode('utf-8')) # Sending the word 225 | self.log('>>> {}'.format(word)) 226 | self.sock.sendall(b'\x00') # Send zero length word to mark end of the sentence 227 | self.log('') 228 | 229 | # Receiving part of the conversation 230 | 231 | # Will continue receiving until receives '!done' or some kind of error (!trap). 232 | # Everything will be appended to paragraph variable, and then returned. 233 | paragraph = [] 234 | received_sentence = [''] 235 | while received_sentence[0] != '!done': 236 | received_sentence = read_sentence() 237 | paragraph.append(received_sentence) 238 | return paragraph 239 | 240 | # Initiate a conversation with the router 241 | def talk(self, message): 242 | 243 | # It is possible for message to be string, tuple or list containing multiple strings or tuples 244 | if type(message) == str or type(message) == tuple: 245 | return self.send(message) 246 | elif type(message) == list: 247 | reply = [] 248 | for sentence in message: 249 | reply.append(self.send(sentence)) 250 | return reply 251 | else: 252 | raise TypeError('talk() argument must be str or tuple containing str or list containing str or tuples') 253 | 254 | def send(self, sentence): 255 | # If sentence is string, not tuples of strings, it must be divided in words 256 | if type(sentence) == str: 257 | sentence = sentence.split() 258 | reply = self.communicate(sentence) 259 | 260 | # If RouterOS returns error from command that was sent 261 | if '!trap' in reply[0][0]: 262 | # You can comment following line out if you don't want to raise an error in case of !trap 263 | raise RouterOSTrapError("\nCommand: {}\nReturned an error: {}".format(sentence, reply)) 264 | pass 265 | 266 | # reply is list containing strings with RAW output form API 267 | # nice_reply is a list containing output form API sorted in dictionary for easier use later 268 | nice_reply = [] 269 | for m in range(len(reply) - 1): 270 | nice_reply.append({}) 271 | for k, v in (x[1:].split('=', 1) for x in reply[m][1:]): 272 | nice_reply[m][k] = v 273 | return nice_reply 274 | 275 | def is_alive(self) -> bool: 276 | """Check if socket is alive and router responds""" 277 | 278 | # Check if socket is open in this end 279 | try: 280 | self.sock.settimeout(2) 281 | except OSError: 282 | self.log("Socket is closed.") 283 | return False 284 | 285 | # Check if we can send and receive through socket 286 | try: 287 | self.talk('/system/identity/print') 288 | 289 | except (socket.timeout, IndexError, BrokenPipeError): 290 | self.log("Router does not respond, closing socket.") 291 | self.close() 292 | return False 293 | 294 | self.log("Socket is open, router responds.") 295 | self.sock.settimeout(self.timeout) 296 | return True 297 | 298 | def create_connection(self): 299 | """Create API connection 300 | 301 | 1. Open socket 302 | 2. Log into router 303 | """ 304 | self.open_socket() 305 | self.login() 306 | 307 | def close(self): 308 | self.sock.close() 309 | --------------------------------------------------------------------------------