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