1# This code is part of Ansible, but is an independent component.
2# This particular file snippet, and this file snippet only, is BSD licensed.
3# Modules you write using this snippet, which is embedded dynamically by Ansible
4# still belong to the author of the module, and may assign their own license
5# to the complete work.
6#
7# (c) 2017-2020 Fortinet, Inc
8# All rights reserved.
9#
10# Redistribution and use in source and binary forms, with or without modification,
11# are permitted provided that the following conditions are met:
12#
13#    * Redistributions of source code must retain the above copyright
14#      notice, this list of conditions and the following disclaimer.
15#    * Redistributions in binary form must reproduce the above copyright notice,
16#      this list of conditions and the following disclaimer in the documentation
17#      and/or other materials provided with the distribution.
18#
19# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
22# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
23# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
24# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
26# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
27# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28from __future__ import (absolute_import, division, print_function)
29__metaclass__ = type
30
31
32# BEGIN STATIC DATA / MESSAGES
33class FMGRMethods:
34    GET = "get"
35    SET = "set"
36    EXEC = "exec"
37    EXECUTE = "exec"
38    UPDATE = "update"
39    ADD = "add"
40    DELETE = "delete"
41    REPLACE = "replace"
42    CLONE = "clone"
43    MOVE = "move"
44
45
46BASE_HEADERS = {
47    'Content-Type': 'application/json',
48    'Accept': 'application/json'
49}
50
51
52# FMGR RETURN CODES
53FMGR_RC = {
54    "fmgr_return_codes": {
55        0: {
56            "msg": "OK",
57            "changed": True,
58            "stop_on_success": True
59        },
60        -100000: {
61            "msg": "Module returned without actually running anything. "
62            "Check parameters, and please contact the authors if needed.",
63            "failed": True
64        },
65        -2: {
66            "msg": "Object already exists.",
67            "skipped": True,
68            "changed": False,
69            "good_codes": [0, -2]
70        },
71        -6: {
72            "msg": "Invalid Url. Sometimes this can happen because the path is mapped to a hostname or object that"
73            " doesn't exist. Double check your input object parameters."
74        },
75        -3: {
76            "msg": "Object doesn't exist.",
77            "skipped": True,
78            "changed": False,
79            "good_codes": [0, -3]
80        },
81        -10131: {
82            "msg": "Object dependency failed. Do all named objects in parameters exist?",
83            "changed": False,
84            "skipped": True
85        },
86        -9998: {
87            "msg": "Duplicate object. Try using mode='set', if using add. STOPPING. Use 'ignore_errors=yes' in playbook"
88            "to override and mark successful.",
89        },
90        -20042: {
91            "msg": "Device Unreachable.",
92            "skipped": True
93        },
94        -10033: {
95            "msg": "Duplicate object. Try using mode='set', if using add.",
96            "changed": False,
97            "skipped": True
98        },
99        -10000: {
100            "msg": "Duplicate object. Try using mode='set', if using add.",
101            "changed": False,
102            "skipped": True
103        },
104        -20010: {
105            "msg": "Device already added to FortiManager. Serial number already in use.",
106            "good_codes": [0, -20010],
107            "changed": False,
108            "stop_on_success": True
109        },
110        -20002: {
111            "msg": "Invalid Argument -- Does this Device exist on FortiManager?",
112            "changed": False,
113            "skipped": True,
114        }
115    }
116}
117
118DEFAULT_RESULT_OBJ = (-100000, {"msg": "Nothing Happened. Check that handle_response is being called!"})
119FAIL_SOCKET_MSG = {"msg": "Socket Path Empty! The persistent connection manager is messed up. "
120                   "Try again in a few moments."}
121
122
123# BEGIN ERROR EXCEPTIONS
124class FMGBaseException(Exception):
125    """Wrapper to catch the unexpected"""
126
127    def __init__(self, msg=None, *args, **kwargs):
128        if msg is None:
129            msg = "An exception occurred within the fortimanager.py httpapi connection plugin."
130        super(FMGBaseException, self).__init__(msg, *args)
131
132# END ERROR CLASSES
133
134
135# BEGIN CLASSES
136class FMGRCommon(object):
137
138    @staticmethod
139    def format_request(method, url, *args, **kwargs):
140        """
141        Formats the payload from the module, into a payload the API handler can use.
142
143        :param url: Connection URL to access
144        :type url: string
145        :param method: The preferred API Request method (GET, ADD, POST, etc....)
146        :type method: basestring
147        :param kwargs: The payload dictionary from the module to be converted.
148
149        :return: Properly formatted dictionary payload for API Request via Connection Plugin.
150        :rtype: dict
151        """
152
153        params = [{"url": url}]
154        if args:
155            for arg in args:
156                params[0].update(arg)
157        if kwargs:
158            keylist = list(kwargs)
159            for k in keylist:
160                kwargs[k.replace("__", "-")] = kwargs.pop(k)
161            if method == "get" or method == "clone":
162                params[0].update(kwargs)
163            else:
164                if kwargs.get("data", False):
165                    params[0]["data"] = kwargs["data"]
166                else:
167                    params[0]["data"] = kwargs
168        return params
169
170    @staticmethod
171    def split_comma_strings_into_lists(obj):
172        """
173        Splits a CSV String into a list.  Also takes a dictionary, and converts any CSV strings in any key, to a list.
174
175        :param obj: object in CSV format to be parsed.
176        :type obj: str or dict
177
178        :return: A list containing the CSV items.
179        :rtype: list
180        """
181        return_obj = ()
182        if isinstance(obj, dict):
183            if len(obj) > 0:
184                for k, v in obj.items():
185                    if isinstance(v, str):
186                        new_list = list()
187                        if "," in v:
188                            new_items = v.split(",")
189                            for item in new_items:
190                                new_list.append(item.strip())
191                            obj[k] = new_list
192                return_obj = obj
193        elif isinstance(obj, str):
194            return_obj = obj.replace(" ", "").split(",")
195
196        return return_obj
197
198    @staticmethod
199    def cidr_to_netmask(cidr):
200        """
201        Converts a CIDR Network string to full blown IP/Subnet format in decimal format.
202        Decided not use IP Address module to keep includes to a minimum.
203
204        :param cidr: String object in CIDR format to be processed
205        :type cidr: str
206
207        :return: A string object that looks like this "x.x.x.x/y.y.y.y"
208        :rtype: str
209        """
210        if isinstance(cidr, str):
211            cidr = int(cidr)
212            mask = (0xffffffff >> (32 - cidr)) << (32 - cidr)
213            return (str((0xff000000 & mask) >> 24) + '.' + str((0xff0000 & mask) >> 16) + '.' + str((0x0000ff00 & mask) >> 8) + '.' + str((0x000000ff & mask)))
214
215    @staticmethod
216    def paramgram_child_list_override(list_overrides, paramgram, module):
217        """
218        If a list of items was provided to a "parent" paramgram attribute, the paramgram needs to be rewritten.
219        The child keys of the desired attribute need to be deleted, and then that "parent" keys' contents is replaced
220        With the list of items that was provided.
221
222        :param list_overrides: Contains the response from the FortiManager.
223        :type list_overrides: list
224        :param paramgram: Contains the paramgram passed to the modules' local modify function.
225        :type paramgram: dict
226        :param module: Contains the Ansible Module Object being used by the module.
227        :type module: classObject
228
229        :return: A new "paramgram" refactored to allow for multiple entries being added.
230        :rtype: dict
231        """
232        if len(list_overrides) > 0:
233            for list_variable in list_overrides:
234                try:
235                    list_variable = list_variable.replace("-", "_")
236                    override_data = module.params[list_variable]
237                    if override_data:
238                        del paramgram[list_variable]
239                        paramgram[list_variable] = override_data
240                except BaseException as e:
241                    raise FMGBaseException("Error occurred merging custom lists for the paramgram parent: " + str(e))
242        return paramgram
243
244    @staticmethod
245    def syslog(module, msg):
246        try:
247            module.log(msg=msg)
248        except BaseException:
249            pass
250
251    def _report_schema_violation(self, param, schema, detail):
252        """
253        the helper function which fortmats the error message.
254
255        :param param: the parameters which are going to be matched.
256        :type param: dict
257        :param schema: the schemas which are going to be matched with.
258        :type schema: dict
259        :param detail: the hint message which reveals the sort of violation message.
260        :type detail: string
261
262        :return: the status along with formatted error message string
263        :rtype: tuple
264        """
265        return False, 'param:%s does not match schema:%s, detail:%s' % (param, schema, detail)
266
267    def _validate_param_recursivly(self, param, schema):
268        """
269        the routine which recursively validate the provided parameters and schemas.
270
271        :param param: the parameters which are going to be matched.
272        :type param: dict
273        :param schema: the schemas which are going to be matched with.
274        :type schema: dict
275
276        :return: the status along with formatted error message string
277        :rtype: tuple
278                """
279        param_key = None if not isinstance(param, dict) else list(param.keys())[0]
280        param_value = param if not isinstance(param, dict) else param[param_key]
281
282        if 'type' not in schema or schema['type'] not in ['string', 'integer', 'array', 'dict']:
283            if not isinstance(param, dict) or not isinstance(schema, dict):
284                return self._report_schema_violation(param, schema, 'unrecognized failure')
285            for discrete_param_key in param:
286                discrete_param_value = param[discrete_param_key]
287                if discrete_param_key not in schema and (len(schema) != 1 or
288                   not list(schema.keys())[0].startswith('{') or
289                   not list(schema.keys())[0].endswith('}')):
290                    return self._report_schema_violation(discrete_param_key, schema, 'no available schema found')
291                per_param_schema = schema[list(schema.keys())[0]]
292                if discrete_param_key in schema:
293                    per_param_schema = schema[discrete_param_key]
294                result, message = self._validate_param_recursivly(discrete_param_value, per_param_schema)
295                if not result:
296                    return result, message
297            return True, ''
298
299        if schema['type'] == 'string':
300            if not isinstance(param_value, str):
301                return self._report_schema_violation(param, schema, 'type mismatch')
302            if 'enum' in schema and param_value not in schema['enum']:
303                return self._report_schema_violation(param, schema, 'enum value mismatch')
304        elif schema['type'] == 'integer':
305            if not isinstance(param_value, int):
306                return self._report_schema_violation(param, schema, 'type mismatch')
307            if 'enum' in schema and param_value not in schema['enum']:
308                return self._report_schema_violation(param, schema, 'enum value mismatch')
309        elif schema['type'] == 'array':
310            if 'items' not in schema:
311                raise AssertionError('\'items\' not in schema:%s' % (schema))
312            if not isinstance(param_value, list):
313                return self._report_schema_violation(param, schema, 'type mismatch')
314            for elem in param_value:
315                result, message = self._validate_param_recursivly(elem, schema['items'])
316                if not result:
317                    return result, message
318        elif schema['type'] == 'dict':
319            if not isinstance(param, dict):
320                return self._report_schema_violation(param, schema, 'type mismatch')
321            if len(list(param.keys())) != 1 or list(param.keys())[0] != schema['name']:
322                return self._report_schema_violation(param, schema, 'schema content mismatch')
323            if 'dict' not in schema:
324                raise AssertionError('\'dict\' not in schema:%s' % (schema))
325            return self._validate_param_recursivly(param[schema['name']], schema['dict'])
326        return True, ''
327
328    def _validate_param_block(self, param_block, tagged_schema):
329        """
330        the subordinate routines to validate a tagged parameter block
331
332        :param param_block: the tagged parameters block which are going to be matched.
333        :type param_block: dict
334        :param tagged_schema: the tagged schemas which are going to be matched with.
335        :type tagged_schema: dict
336
337        :return: the status along with formatted error message string
338        :rtype: tuple
339        """
340        for param_item_name in param_block:
341            param_item = {param_item_name: param_block[param_item_name]}
342            schema_item = None
343            for schema_desc in tagged_schema:
344                if schema_desc['name'] == param_item_name:
345                    schema_item = schema_desc
346                    break
347            if not schema_item:
348                return False, 'unrecognized parameter: %s' % (param_item_name)
349            result, message = self._validate_param_recursivly(param_item,
350                                                              schema_item)
351            if not result:
352                return result, message
353        return True, 'parameter block validation succeeds'
354
355    def validate_module_params(self, module, schemas):
356        """
357        the routine to validate input parameters.
358
359        :param module: the Ansible module structure.
360        :type module: AnsibleModule
361        :param schemas: the schemas which are going to be matched with.
362        :type schemas: dict
363
364        :return: the status along with formatted error message string
365        :rtype: tuple
366        """
367        method = module.params['method']
368
369        # categorize schema item according to its api_tag.
370        if method not in schemas['method_mapping']:
371            raise FMGBaseException('method:%s not supported in schema' % (method))
372        schema = schemas['schema_objects'][schemas['method_mapping'][method]]
373
374        tagged_schemas = dict()
375        for item in schema:
376            if item['name'] == 'url':
377                continue
378            api_tag = item['api_tag']
379            if api_tag not in tagged_schemas:
380                tagged_schemas[api_tag] = list()
381            tagged_schemas[api_tag].append(item)
382        # if no parameters, we skip the validation phase
383        if not module.params['params']:
384            return
385
386        for param_block in module.params['params']:
387            # in case there are more than one api tag for the url, we check it one by one
388            # until we encounter an explicit failure
389            validation_result = False
390            validation_message = None
391            for tagged_schema_key in tagged_schemas:
392                tagged_schema = tagged_schemas[tagged_schema_key]
393                result, message = self._validate_param_block(param_block,
394                                                             tagged_schema)
395                validation_result |= result
396                if not result:
397                    validation_message = message
398                else:
399                    break
400            if not validation_result:
401                raise FMGBaseException('parameter validation fails: %s'
402                                       % (validation_message))
403
404    def validate_module_url_params(self, module, jrpc_urls, raw_url_schema):
405        """
406        validate whether the given paramters in url match their schema counterpart.
407
408        :param module: the Ansible module structure.
409        :type module: AnsibleModule
410        :param jrpc_urls: the parameters in url
411        :type jrpc_urls: list
412        :param raw_url_schema: the schemas to be matched with.
413        :type raw_url_schema: list
414
415        :return: None
416        :rtype: Exception maybe raised.
417        """
418        raw_url_params = module.params['url_params']
419        # if no url_schema is provided, it's a solo url_no_domain
420        if not len(raw_url_schema):
421            if raw_url_params and len(raw_url_params):
422                raise FMGBaseException('the module expects no url params')
423            else:
424                return
425
426        url_schema = list()
427        url_params = dict()
428        adom_value = 'none'
429        if 'adom' in adom_value:
430            adom_value = raw_url_params['adom'].lower()
431
432        if adom_value == 'none' or adom_value == 'global':
433            for item in raw_url_schema:
434                if item['name'] == 'adom':
435                    continue
436                url_schema.append(item)
437            for param_key in raw_url_params:
438                if param_key == 'adom':
439                    continue
440                url_params[param_key] = raw_url_params[param_key]
441        else:
442            url_schema = raw_url_schema
443            url_params = raw_url_params
444        # do legacy validation.
445        if not len(url_schema):
446            return
447
448        if not url_params or len(url_params) != len(url_schema):
449            raise FMGBaseException('mismatched pameters, full list:%s' % (
450                                   [item['name'] for item in url_schema]))
451        param_key_set = set(list(url_params.keys()))
452        schema_key_set = set([item['name'] for item in url_schema])
453        if param_key_set != schema_key_set:
454            raise FMGBaseException('url parameter %s does not match schema %s' % (
455                                   param_key_set, schema_key_set))
456        for param_key in url_params:
457            param = url_params[param_key]
458            schema = None
459            for schema_item in url_schema:
460                if schema_item['name'] == param_key:
461                    schema = schema_item
462                    break
463            if not schema:
464                raise AssertionError('\'schema\' is None')
465            if schema['type'] == 'string' and not isinstance(param, str) or \
466               schema['type'] == 'integer' and not isinstance(param, int):
467                raise FMGBaseException('url parameter %s does not schema %s' % (
468                                       param, schema))
469
470    def get_full_url_path(self, module, jrpc_urls):
471        """
472        format the full url string for json-rpc.
473
474        :param module: the Ansible module structure.
475        :type module: AnsibleModule
476        :param jrpc_urls: the parameters in url
477        :type jrpc_urls: list
478
479        :return: the url string.
480        :rtype: string
481        """
482        url_params = module.params['url_params']
483        url_custom_domain = None
484        url_global_domain = None
485        url_no_domain = None
486        url_format = None
487        for _url in jrpc_urls:
488            if '/adom/{adom}/' in _url or _url.endswith('/adom/{adom}'):
489                url_custom_domain = _url
490            elif '/global/' in _url:
491                url_global_domain = _url
492            else:
493                url_no_domain = _url
494        if not url_params or 'adom' not in url_params:
495            url_format = url_no_domain
496        elif url_params['adom'] == 'global':
497            url_format = url_global_domain
498        elif url_params['adom'] == 'none':
499            url_format = url_no_domain
500        else:
501            url_format = url_custom_domain
502        if not url_format:
503            raise AssertionError('\'url_format\' is None')
504        return url_format if not url_params else url_format.format(**url_params)
505
506    def get_full_payload(self, module, full_url):
507        """
508        construct the full payload including url for json-rpc
509
510        :param module: the Ansible module structure.
511        :type module: AnsibleModule
512        :param jrpc_urls: the parameters in url
513        :type jrpc_urls: list
514
515        :return: the payload list
516        :rtype: list
517        """
518        payload_list = list()
519        params_blocks = module.params['params']
520        if params_blocks:
521            for params_block in params_blocks:
522                payload = dict()
523                payload['url'] = full_url
524                for top_level_param_key in params_block:
525                    top_level_param = params_block[top_level_param_key]
526                    payload[top_level_param_key] = top_level_param
527                payload_list.append(payload)
528        else:
529            # There is one exception that no params is provided, the url is only one in the request
530            payload_list.append({'url': full_url})
531        return payload_list
532
533
534# RECURSIVE FUNCTIONS START
535def prepare_dict(obj):
536    """
537    Removes any keys from a dictionary that are only specific to our use in the module. FortiManager will reject
538    requests with these empty/None keys in it.
539
540    :param obj: Dictionary object to be processed.
541    :type obj: dict
542
543    :return: Processed dictionary.
544    :rtype: dict
545    """
546
547    list_of_elems = ["mode", "adom", "host", "username", "password"]
548
549    if isinstance(obj, dict):
550        obj = dict((key, prepare_dict(value)) for (key, value) in obj.items() if key not in list_of_elems)
551    return obj
552
553
554def scrub_dict(obj):
555    """
556    Removes any keys from a dictionary that are EMPTY -- this includes parent keys. FortiManager doesn't
557    like empty keys in dictionaries
558
559    :param obj: Dictionary object to be processed.
560    :type obj: dict
561
562    :return: Processed dictionary.
563    :rtype: dict
564    """
565
566    if isinstance(obj, dict):
567        return dict((k, scrub_dict(v)) for k, v in obj.items() if v and scrub_dict(v))
568    else:
569        return obj
570