1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# Copyright: (c) 2020, Nikolay Dachev <nikolay@dachev.info> 5# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt 6 7from __future__ import (absolute_import, division, print_function) 8__metaclass__ = type 9 10DOCUMENTATION = ''' 11--- 12module: api 13author: "Nikolay Dachev (@NikolayDachev)" 14short_description: Ansible module for RouterOS API 15description: 16 - Ansible module for RouterOS API with python librouteros. 17 - This module can add, remove, update, query and execute arbitrary command in routeros via API. 18notes: 19 - I(add), I(remove), I(update), I(cmd) and I(query) are mutually exclusive. 20 - I(check_mode) is not supported. 21requirements: 22 - librouteros 23 - Python >= 3.6 (for librouteros) 24options: 25 hostname: 26 description: 27 - RouterOS hostname API. 28 required: true 29 type: str 30 username: 31 description: 32 - RouterOS login user. 33 required: true 34 type: str 35 password: 36 description: 37 - RouterOS user password. 38 required: true 39 type: str 40 tls: 41 description: 42 - If is set TLS will be used for RouterOS API connection. 43 required: false 44 type: bool 45 default: false 46 aliases: 47 - ssl 48 port: 49 description: 50 - RouterOS api port. If I(tls) is set, port will apply to TLS/SSL connection. 51 - Defaults are C(8728) for the HTTP API, and C(8729) for the HTTPS API. 52 type: int 53 path: 54 description: 55 - Main path for all other arguments. 56 - If other arguments are not set, api will return all items in selected path. 57 - Example C(ip address). Equivalent of RouterOS CLI C(/ip address print). 58 required: true 59 type: str 60 add: 61 description: 62 - Will add selected arguments in selected path to RouterOS config. 63 - Example C(address=1.1.1.1/32 interface=ether1). 64 - Equivalent in RouterOS CLI C(/ip address add address=1.1.1.1/32 interface=ether1). 65 type: str 66 remove: 67 description: 68 - Remove config/value from RouterOS by '.id'. 69 - Example C(*03) will remove config/value with C(id=*03) in selected path. 70 - Equivalent in RouterOS CLI C(/ip address remove numbers=1). 71 - Note C(number) in RouterOS CLI is different from C(.id). 72 type: str 73 update: 74 description: 75 - Update config/value in RouterOS by '.id' in selected path. 76 - Example C(.id=*03 address=1.1.1.3/32) and path C(ip address) will replace existing ip address with C(.id=*03). 77 - Equivalent in RouterOS CLI C(/ip address set address=1.1.1.3/32 numbers=1). 78 - Note C(number) in RouterOS CLI is different from C(.id). 79 type: str 80 query: 81 description: 82 - Query given path for selected query attributes from RouterOS aip and return '.id'. 83 - WHERE is key word which extend query. WHERE format is key operator value - with spaces. 84 - WHERE valid operators are C(==), C(!=), C(>), C(<). 85 - Example path C(ip address) and query C(.id address) will return only C(.id) and C(address) for all items in C(ip address) path. 86 - Example path C(ip address) and query C(.id address WHERE address == 1.1.1.3/32). 87 will return only C(.id) and C(address) for items in C(ip address) path, where address is eq to 1.1.1.3/32. 88 - Example path C(interface) and query C(mtu name WHERE mut > 1400) will 89 return only interfaces C(mtu,name) where mtu is bigger than 1400. 90 - Equivalent in RouterOS CLI C(/interface print where mtu > 1400). 91 type: str 92 cmd: 93 description: 94 - Execute any/arbitrary command in selected path, after the command we can add C(.id). 95 - Example path C(system script) and cmd C(run .id=*03) is equivalent in RouterOS CLI C(/system script run number=0). 96 - Example path C(ip address) and cmd C(print) is equivalent in RouterOS CLI C(/ip address print). 97 type: str 98 validate_certs: 99 description: 100 - Set to C(false) to skip validation of TLS certificates. 101 - See also I(validate_cert_hostname). Only used when I(tls=true). 102 - B(Note:) instead of simply deactivating certificate validations to "make things work", 103 please consider creating your own CA certificate and using it to sign certificates used 104 for your router. You can tell the module about your CA certificate with the I(ca_path) 105 option. 106 type: bool 107 default: true 108 version_added: 1.2.0 109 validate_cert_hostname: 110 description: 111 - Set to C(true) to validate hostnames in certificates. 112 - See also I(validate_certs). Only used when I(tls=true) and I(validate_certs=true). 113 type: bool 114 default: false 115 version_added: 1.2.0 116 ca_path: 117 description: 118 - PEM formatted file that contains a CA certificate to be used for certificate validation. 119 - See also I(validate_cert_hostname). Only used when I(tls=true) and I(validate_certs=true). 120 type: path 121 version_added: 1.2.0 122''' 123 124EXAMPLES = ''' 125--- 126- name: Use RouterOS API 127 hosts: localhost 128 gather_facts: no 129 vars: 130 hostname: "ros_api_hostname/ip" 131 username: "admin" 132 password: "secret_password" 133 134 path: "ip address" 135 136 nic: "ether2" 137 ip1: "1.1.1.1/32" 138 ip2: "2.2.2.2/32" 139 ip3: "3.3.3.3/32" 140 141 tasks: 142 - name: Get "{{ path }} print" 143 community.routeros.api: 144 hostname: "{{ hostname }}" 145 password: "{{ password }}" 146 username: "{{ username }}" 147 path: "{{ path }}" 148 register: print_path 149 150 - name: Dump "{{ path }} print" output 151 ansible.builtin.debug: 152 msg: '{{ print_path }}' 153 154 - name: Add ip address "{{ ip1 }}" and "{{ ip2 }}" 155 community.routeros.api: 156 hostname: "{{ hostname }}" 157 password: "{{ password }}" 158 username: "{{ username }}" 159 path: "{{ path }}" 160 add: "{{ item }}" 161 loop: 162 - "address={{ ip1 }} interface={{ nic }}" 163 - "address={{ ip2 }} interface={{ nic }}" 164 register: addout 165 166 - name: Dump "Add ip address" output - ".id" for new added items 167 ansible.builtin.debug: 168 msg: '{{ addout }}' 169 170 - name: Query for ".id" in "{{ path }} WHERE address == {{ ip2 }}" 171 community.routeros.api: 172 hostname: "{{ hostname }}" 173 password: "{{ password }}" 174 username: "{{ username }}" 175 path: "{{ path }}" 176 query: ".id address WHERE address == {{ ip2 }}" 177 register: queryout 178 179 - name: Dump "Query for" output and set fact with ".id" for "{{ ip2 }}" 180 ansible.builtin.debug: 181 msg: '{{ queryout }}' 182 183 - name: Store query_id for later usage 184 ansible.builtin.set_fact: 185 query_id: "{{ queryout['msg'][0]['.id'] }}" 186 187 - name: Update ".id = {{ query_id }}" taken with custom fact "fquery_id" 188 community.routeros.api: 189 hostname: "{{ hostname }}" 190 password: "{{ password }}" 191 username: "{{ username }}" 192 path: "{{ path }}" 193 update: ".id={{ query_id }} address={{ ip3 }}" 194 register: updateout 195 196 - name: Dump "Update" output 197 ansible.builtin.debug: 198 msg: '{{ updateout }}' 199 200 - name: Remove ips - stage 1 - query ".id" for "{{ ip2 }}" and "{{ ip3 }}" 201 community.routeros.api: 202 hostname: "{{ hostname }}" 203 password: "{{ password }}" 204 username: "{{ username }}" 205 path: "{{ path }}" 206 query: ".id address WHERE address == {{ item }}" 207 register: id_to_remove 208 loop: 209 - "{{ ip2 }}" 210 - "{{ ip3 }}" 211 212 - name: Set fact for ".id" from "Remove ips - stage 1 - query" 213 ansible.builtin.set_fact: 214 to_be_remove: "{{ to_be_remove |default([]) + [item['msg'][0]['.id']] }}" 215 loop: "{{ id_to_remove.results }}" 216 217 - name: Dump "Remove ips - stage 1 - query" output 218 ansible.builtin.debug: 219 msg: '{{ to_be_remove }}' 220 221 # Remove "{{ rmips }}" with ".id" by "to_be_remove" from query 222 - name: Remove ips - stage 2 - remove "{{ ip2 }}" and "{{ ip3 }}" by '.id' 223 community.routeros.api: 224 hostname: "{{ hostname }}" 225 password: "{{ password }}" 226 username: "{{ username }}" 227 path: "{{ path }}" 228 remove: "{{ item }}" 229 register: remove 230 loop: "{{ to_be_remove }}" 231 232 - name: Dump "Remove ips - stage 2 - remove" output 233 ansible.builtin.debug: 234 msg: '{{ remove }}' 235 236 - name: Arbitrary command example "/system identity print" 237 community.routeros.api: 238 hostname: "{{ hostname }}" 239 password: "{{ password }}" 240 username: "{{ username }}" 241 path: "system identity" 242 cmd: "print" 243 register: cmdout 244 245 - name: Dump "Arbitrary command example" output 246 ansible.builtin.debug: 247 msg: "{{ cmdout }}" 248''' 249 250RETURN = ''' 251--- 252message: 253 description: All outputs are in list with dictionary elements returned from RouterOS api. 254 sample: C([{...},{...}]) 255 type: list 256 returned: always 257''' 258 259from ansible.module_utils.basic import AnsibleModule 260from ansible.module_utils.basic import missing_required_lib 261from ansible.module_utils.common.text.converters import to_native 262 263import ssl 264import traceback 265 266LIB_IMP_ERR = None 267try: 268 from librouteros import connect 269 from librouteros.query import Key 270 HAS_LIB = True 271except Exception as e: 272 HAS_LIB = False 273 LIB_IMP_ERR = traceback.format_exc() 274 275 276class ROS_api_module: 277 def __init__(self): 278 module_args = dict( 279 username=dict(type='str', required=True), 280 password=dict(type='str', required=True, no_log=True), 281 hostname=dict(type='str', required=True), 282 port=dict(type='int'), 283 tls=dict(type='bool', default=False, aliases=['ssl']), 284 path=dict(type='str', required=True), 285 add=dict(type='str'), 286 remove=dict(type='str'), 287 update=dict(type='str'), 288 cmd=dict(type='str'), 289 query=dict(type='str'), 290 validate_certs=dict(type='bool', default=True), 291 validate_cert_hostname=dict(type='bool', default=False), 292 ca_path=dict(type='path'), 293 ) 294 295 self.module = AnsibleModule(argument_spec=module_args, 296 supports_check_mode=False, 297 mutually_exclusive=(('add', 'remove', 'update', 298 'cmd', 'query'),),) 299 300 if not HAS_LIB: 301 self.module.fail_json(msg=missing_required_lib("librouteros"), 302 exception=LIB_IMP_ERR) 303 304 self.api = self.ros_api_connect(self.module.params['username'], 305 self.module.params['password'], 306 self.module.params['hostname'], 307 self.module.params['port'], 308 self.module.params['tls'], 309 self.module.params['validate_certs'], 310 self.module.params['validate_cert_hostname'], 311 self.module.params['ca_path'], 312 ) 313 314 self.path = self.list_remove_empty(self.module.params['path'].split(' ')) 315 self.add = self.module.params['add'] 316 self.remove = self.module.params['remove'] 317 self.update = self.module.params['update'] 318 self.arbitrary = self.module.params['cmd'] 319 320 self.where = None 321 self.query = self.module.params['query'] 322 if self.query: 323 if 'WHERE' in self.query: 324 split = self.query.split('WHERE') 325 self.query = self.list_remove_empty(split[0].split(' ')) 326 self.where = self.list_remove_empty(split[1].split(' ')) 327 else: 328 self.query = self.list_remove_empty(self.module.params['query'].split(' ')) 329 330 self.result = dict( 331 message=[]) 332 333 # create api base path 334 self.api_path = self.api_add_path(self.api, self.path) 335 336 # api call's 337 if self.add: 338 self.api_add() 339 elif self.remove: 340 self.api_remove() 341 elif self.update: 342 self.api_update() 343 elif self.query: 344 self.api_query() 345 elif self.arbitrary: 346 self.api_arbitrary() 347 else: 348 self.api_get_all() 349 350 def list_remove_empty(self, check_list): 351 while("" in check_list): 352 check_list.remove("") 353 return check_list 354 355 def list_to_dic(self, ldict): 356 dict = {} 357 for p in ldict: 358 if '=' not in p: 359 self.errors("missing '=' after '%s'" % p) 360 p = p.split('=') 361 if p[1]: 362 dict[p[0]] = p[1] 363 return dict 364 365 def api_add_path(self, api, path): 366 api_path = api.path() 367 for p in path: 368 api_path = api_path.join(p) 369 return api_path 370 371 def api_get_all(self): 372 try: 373 for i in self.api_path: 374 self.result['message'].append(i) 375 self.return_result(False, True) 376 except Exception as e: 377 self.errors(e) 378 379 def api_add(self): 380 param = self.list_to_dic(self.add.split(' ')) 381 try: 382 self.result['message'].append("added: .id= %s" 383 % self.api_path.add(**param)) 384 self.return_result(True) 385 except Exception as e: 386 self.errors(e) 387 388 def api_remove(self): 389 try: 390 self.api_path.remove(self.remove) 391 self.result['message'].append("removed: .id= %s" % self.remove) 392 self.return_result(True) 393 except Exception as e: 394 self.errors(e) 395 396 def api_update(self): 397 param = self.list_to_dic(self.update.split(' ')) 398 if '.id' not in param.keys(): 399 self.errors("missing '.id' for %s" % param) 400 try: 401 self.api_path.update(**param) 402 self.result['message'].append("updated: %s" % param) 403 self.return_result(True) 404 except Exception as e: 405 self.errors(e) 406 407 def api_query(self): 408 keys = {} 409 for k in self.query: 410 if 'id' in k and k != ".id": 411 self.errors("'%s' must be '.id'" % k) 412 keys[k] = Key(k) 413 try: 414 if self.where: 415 if len(self.where) < 3: 416 self.errors("invalid syntax for 'WHERE %s'" 417 % ' '.join(self.where)) 418 419 where = [] 420 if self.where[1] == '==': 421 select = self.api_path.select(*keys).where(keys[self.where[0]] == self.where[2]) 422 elif self.where[1] == '!=': 423 select = self.api_path.select(*keys).where(keys[self.where[0]] != self.where[2]) 424 elif self.where[1] == '>': 425 select = self.api_path.select(*keys).where(keys[self.where[0]] > self.where[2]) 426 elif self.where[1] == '<': 427 select = self.api_path.select(*keys).where(keys[self.where[0]] < self.where[2]) 428 else: 429 self.errors("'%s' is not operator for 'where'" 430 % self.where[1]) 431 for row in select: 432 self.result['message'].append(row) 433 else: 434 for row in self.api_path.select(*keys): 435 self.result['message'].append(row) 436 if len(self.result['message']) < 1: 437 msg = "no results for '%s 'query' %s" % (' '.join(self.path), 438 ' '.join(self.query)) 439 if self.where: 440 msg = msg + ' WHERE %s' % ' '.join(self.where) 441 self.result['message'].append(msg) 442 self.return_result(False) 443 except Exception as e: 444 self.errors(e) 445 446 def api_arbitrary(self): 447 param = {} 448 self.arbitrary = self.arbitrary.split(' ') 449 arb_cmd = self.arbitrary[0] 450 if len(self.arbitrary) > 1: 451 param = self.list_to_dic(self.arbitrary[1:]) 452 try: 453 arbitrary_result = self.api_path(arb_cmd, **param) 454 for i in arbitrary_result: 455 self.result['message'].append(i) 456 self.return_result(False) 457 except Exception as e: 458 self.errors(e) 459 460 def return_result(self, ch_status=False, status=True): 461 if status == "False": 462 self.module.fail_json(msg=to_native(self.result['message'])) 463 else: 464 self.module.exit_json(changed=ch_status, 465 msg=self.result['message']) 466 467 def errors(self, e): 468 if e.__class__.__name__ == 'TrapError': 469 self.result['message'].append("%s" % e) 470 self.return_result(False, True) 471 self.result['message'].append("%s" % e) 472 self.return_result(False, False) 473 474 def ros_api_connect(self, username, password, host, port, use_tls, validate_certs, validate_cert_hostname, ca_path): 475 # connect to routeros api 476 conn_status = {"connection": {"username": username, 477 "hostname": host, 478 "port": port, 479 "ssl": use_tls, 480 "status": "Connected"}} 481 try: 482 if use_tls: 483 if not port: 484 port = 8729 485 conn_status["connection"]["port"] = port 486 ctx = ssl.create_default_context(cafile=ca_path) 487 wrap_context = ctx.wrap_socket 488 if not validate_certs: 489 ctx.check_hostname = False 490 ctx.verify_mode = ssl.CERT_NONE 491 elif not validate_cert_hostname: 492 ctx.check_hostname = False 493 else: 494 # Since librouteros doesn't pass server_hostname, 495 # we have to do this ourselves: 496 def wrap_context(*args, **kwargs): 497 kwargs.pop('server_hostname', None) 498 return ctx.wrap_socket(*args, server_hostname=host, **kwargs) 499 api = connect(username=username, 500 password=password, 501 host=host, 502 ssl_wrapper=wrap_context, 503 port=port) 504 else: 505 if not port: 506 port = 8728 507 conn_status["connection"]["port"] = port 508 api = connect(username=username, 509 password=password, 510 host=host, 511 port=port) 512 except Exception as e: 513 conn_status["connection"]["status"] = "error: %s" % e 514 self.module.fail_json(msg=to_native([conn_status])) 515 return api 516 517 518def main(): 519 520 ROS_api_module() 521 522 523if __name__ == '__main__': 524 main() 525