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