diff --git a/lib/ansible/module_utils/network/fortimanager/common.py b/lib/ansible/module_utils/network/fortimanager/common.py new file mode 100644 index 0000000000..6da07d4bca --- /dev/null +++ b/lib/ansible/module_utils/network/fortimanager/common.py @@ -0,0 +1,288 @@ +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# (c) 2017 Fortinet, Inc +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +# BEGIN STATIC DATA / MESSAGES +class FMGRMethods: + GET = "get" + SET = "set" + EXEC = "exec" + EXECUTE = "exec" + UPDATE = "update" + ADD = "add" + DELETE = "delete" + REPLACE = "replace" + CLONE = "clone" + MOVE = "move" + + +BASE_HEADERS = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' +} + + +# FMGR RETURN CODES +FMGR_RC = { + "fmgr_return_codes": { + 0: { + "msg": "OK", + "changed": True, + "stop_on_success": True + }, + -100000: { + "msg": "Module returned without actually running anything. " + "Check parameters, and please contact the authors if needed.", + "failed": True + }, + -2: { + "msg": "Object already exists.", + "skipped": True, + "changed": False, + "good_codes": [0, -2] + }, + -6: { + "msg": "Invalid Url. Sometimes this can happen because the path is mapped to a hostname or object that" + " doesn't exist. Double check your input object parameters." + }, + -3: { + "msg": "Object doesn't exist.", + "skipped": True, + "changed": False, + "good_codes": [0, -3] + }, + -10131: { + "msg": "Object dependency failed. Do all named objects in parameters exist?", + "changed": False, + "skipped": True + }, + -9998: { + "msg": "Duplicate object. Try using mode='set', if using add. STOPPING. Use 'ignore_errors=yes' in playbook" + "to override and mark successful.", + }, + -20042: { + "msg": "Device Unreachable.", + "skipped": True + }, + -10033: { + "msg": "Duplicate object. Try using mode='set', if using add.", + "changed": False, + "skipped": True + }, + -10000: { + "msg": "Duplicate object. Try using mode='set', if using add.", + "changed": False, + "skipped": True + }, + -20010: { + "msg": "Device already added to FortiManager. Serial number already in use.", + "good_codes": [0, -20010], + "changed": False, + "stop_on_success": True + }, + -20002: { + "msg": "Invalid Argument -- Does this Device exist on FortiManager?", + "changed": False, + "skipped": True, + } + } +} + +DEFAULT_RESULT_OBJ = (-100000, {"msg": "Nothing Happened. Check that handle_response is being called!"}) +FAIL_SOCKET_MSG = {"msg": "Socket Path Empty! The persistent connection manager is messed up. " + "Try again in a few moments."} + + +# BEGIN ERROR EXCEPTIONS +class FMGBaseException(Exception): + """Wrapper to catch the unexpected""" + + def __init__(self, msg=None, *args, **kwargs): + if msg is None: + msg = "An exception occurred within the fortimanager.py httpapi connection plugin." + super(FMGBaseException, self).__init__(msg, *args) + +# END ERROR CLASSES + + +# BEGIN CLASSES +class FMGRCommon(object): + + @staticmethod + def format_request(method, url, *args, **kwargs): + """ + Formats the payload from the module, into a payload the API handler can use. + + :param url: Connection URL to access + :type url: string + :param method: The preferred API Request method (GET, ADD, POST, etc....) + :type method: basestring + :param kwargs: The payload dictionary from the module to be converted. + + :return: Properly formatted dictionary payload for API Request via Connection Plugin. + :rtype: dict + """ + + params = [{"url": url}] + if args: + for arg in args: + params[0].update(arg) + if kwargs: + keylist = list(kwargs) + for k in keylist: + kwargs[k.replace("__", "-")] = kwargs.pop(k) + if method == "get" or method == "clone": + params[0].update(kwargs) + else: + if kwargs.get("data", False): + params[0]["data"] = kwargs["data"] + else: + params[0]["data"] = kwargs + return params + + @staticmethod + def split_comma_strings_into_lists(obj): + """ + Splits a CSV String into a list. Also takes a dictionary, and converts any CSV strings in any key, to a list. + + :param obj: object in CSV format to be parsed. + :type obj: str or dict + + :return: A list containing the CSV items. + :rtype: list + """ + return_obj = () + if isinstance(obj, dict): + if len(obj) > 0: + for k, v in obj.items(): + if isinstance(v, str): + new_list = list() + if "," in v: + new_items = v.split(",") + for item in new_items: + new_list.append(item.strip()) + obj[k] = new_list + return_obj = obj + elif isinstance(obj, str): + return_obj = obj.replace(" ", "").split(",") + + return return_obj + + @staticmethod + def cidr_to_netmask(cidr): + """ + Converts a CIDR Network string to full blown IP/Subnet format in decimal format. + Decided not use IP Address module to keep includes to a minimum. + + :param cidr: String object in CIDR format to be processed + :type cidr: str + + :return: A string object that looks like this "x.x.x.x/y.y.y.y" + :rtype: str + """ + if isinstance(cidr, str): + cidr = int(cidr) + mask = (0xffffffff >> (32 - cidr)) << (32 - cidr) + return (str((0xff000000 & mask) >> 24) + '.' + + str((0x00ff0000 & mask) >> 16) + '.' + + str((0x0000ff00 & mask) >> 8) + '.' + + str((0x000000ff & mask))) + + @staticmethod + def paramgram_child_list_override(list_overrides, paramgram, module): + """ + If a list of items was provided to a "parent" paramgram attribute, the paramgram needs to be rewritten. + The child keys of the desired attribute need to be deleted, and then that "parent" keys' contents is replaced + With the list of items that was provided. + + :param list_overrides: Contains the response from the FortiManager. + :type list_overrides: list + :param paramgram: Contains the paramgram passed to the modules' local modify function. + :type paramgram: dict + :param module: Contains the Ansible Module Object being used by the module. + :type module: classObject + + :return: A new "paramgram" refactored to allow for multiple entries being added. + :rtype: dict + """ + if len(list_overrides) > 0: + for list_variable in list_overrides: + try: + list_variable = list_variable.replace("-", "_") + override_data = module.params[list_variable] + if override_data: + del paramgram[list_variable] + paramgram[list_variable] = override_data + except BaseException as e: + raise FMGBaseException("Error occurred merging custom lists for the paramgram parent: " + str(e)) + return paramgram + + @staticmethod + def syslog(module, msg): + try: + module.log(msg=msg) + except BaseException: + pass + + +# RECURSIVE FUNCTIONS START +def prepare_dict(obj): + """ + Removes any keys from a dictionary that are only specific to our use in the module. FortiManager will reject + requests with these empty/None keys in it. + + :param obj: Dictionary object to be processed. + :type obj: dict + + :return: Processed dictionary. + :rtype: dict + """ + + list_of_elems = ["mode", "adom", "host", "username", "password"] + + if isinstance(obj, dict): + obj = dict((key, prepare_dict(value)) for (key, value) in obj.items() if key not in list_of_elems) + return obj + + +def scrub_dict(obj): + """ + Removes any keys from a dictionary that are EMPTY -- this includes parent keys. FortiManager doesn't + like empty keys in dictionaries + + :param obj: Dictionary object to be processed. + :type obj: dict + + :return: Processed dictionary. + :rtype: dict + """ + + if isinstance(obj, dict): + return dict((k, scrub_dict(v)) for k, v in obj.items() if v and scrub_dict(v)) + else: + return obj diff --git a/lib/ansible/module_utils/network/fortimanager/fortimanager.py b/lib/ansible/module_utils/network/fortimanager/fortimanager.py index b778745678..fa6c21e933 100644 --- a/lib/ansible/module_utils/network/fortimanager/fortimanager.py +++ b/lib/ansible/module_utils/network/fortimanager/fortimanager.py @@ -27,15 +27,385 @@ # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # -# check for pyFMG lib +from ansible.module_utils.network.fortimanager.common import FMGR_RC +from ansible.module_utils.network.fortimanager.common import FMGBaseException +from ansible.module_utils.network.fortimanager.common import FMGRCommon +from ansible.module_utils.network.fortimanager.common import scrub_dict + +# check for pyFMG lib - DEPRECATING try: from pyFMG.fortimgr import FortiManager HAS_PYFMGR = True except ImportError: HAS_PYFMGR = False +# check for debug lib +try: + from ansible.module_utils.network.fortimanager.fortimanager_debug import debug_dump + HAS_FMGR_DEBUG = True +except ImportError: + HAS_FMGR_DEBUG = False + +# BEGIN HANDLER CLASSES +class FortiManagerHandler(object): + def __init__(self, conn, check_mode=False): + self._conn = conn + self._check_mode = check_mode + self._tools = FMGRCommon + + def process_request(self, url, datagram, method): + """ + Formats and Runs the API Request via Connection Plugin. Streamlined for use FROM Modules. + + :param url: Connection URL to access + :type url: string + :param datagram: The prepared payload for the API Request in dictionary format + :type datagram: dict + :param method: The preferred API Request method (GET, ADD, POST, etc....) + :type method: basestring + + :return: Dictionary containing results of the API Request via Connection Plugin + :rtype: dict + """ + data = self._tools.format_request(method, url, **datagram) + response = self._conn.send_request(method, data) + if HAS_FMGR_DEBUG: + try: + debug_dump(response, datagram, url, method) + except BaseException: + pass + + return response + + def govern_response(self, module, results, msg=None, good_codes=None, + stop_on_fail=None, stop_on_success=None, skipped=None, + changed=None, unreachable=None, failed=None, success=None, changed_if_success=None, + ansible_facts=None): + """ + This function will attempt to apply default values to canned responses from FortiManager we know of. + This saves time, and turns the response in the module into a "one-liner", while still giving us... + the flexibility to directly use return_response in modules if we have too. This function saves repeated code. + + :param module: The Ansible Module CLASS object, used to run fail/exit json + :type module: object + :param msg: An overridable custom message from the module that called this. + :type msg: string + :param results: A dictionary object containing an API call results + :type results: dict + :param good_codes: A list of exit codes considered successful from FortiManager + :type good_codes: list + :param stop_on_fail: If true, stops playbook run when return code is NOT IN good codes (default: true) + :type stop_on_fail: boolean + :param stop_on_success: If true, stops playbook run when return code is IN good codes (default: false) + :type stop_on_success: boolean + :param changed: If True, tells Ansible that object was changed (default: false) + :type skipped: boolean + :param skipped: If True, tells Ansible that object was skipped (default: false) + :type skipped: boolean + :param unreachable: If True, tells Ansible that object was unreachable (default: false) + :type unreachable: boolean + :param failed: If True, tells Ansible that execution was a failure. Overrides good_codes. (default: false) + :type unreachable: boolean + :param success: If True, tells Ansible that execution was a success. Overrides good_codes. (default: false) + :type unreachable: boolean + :param changed_if_success: If True, defaults to changed if successful if you specify or not" + :type changed_if_success: boolean + :param ansible_facts: A prepared dictionary of ansible facts from the execution. + :type ansible_facts: dict + """ + if module is None and results is None: + raise FMGBaseException("govern_response() was called without a module and/or results tuple! Fix!") + # Get the Return code from results + try: + rc = results[0] + except BaseException: + raise FMGBaseException("govern_response() was called without the return code at results[0]") + + # init a few items + rc_data = None + + # Get the default values for the said return code. + try: + rc_codes = FMGR_RC.get('fmgr_return_codes') + rc_data = rc_codes.get(rc) + except BaseException: + pass + + if not rc_data: + rc_data = {} + # ONLY add to overrides if not none -- This is very important that the keys aren't added at this stage + # if they are empty. And there aren't that many, so let's just do a few if then statements. + if good_codes is not None: + rc_data["good_codes"] = good_codes + if stop_on_fail is not None: + rc_data["stop_on_fail"] = stop_on_fail + if stop_on_success is not None: + rc_data["stop_on_success"] = stop_on_success + if skipped is not None: + rc_data["skipped"] = skipped + if changed is not None: + rc_data["changed"] = changed + if unreachable is not None: + rc_data["unreachable"] = unreachable + if failed is not None: + rc_data["failed"] = failed + if success is not None: + rc_data["success"] = success + if changed_if_success is not None: + rc_data["changed_if_success"] = changed_if_success + if results is not None: + rc_data["results"] = results + if msg is not None: + rc_data["msg"] = msg + if ansible_facts is None: + rc_data["ansible_facts"] = {} + else: + rc_data["ansible_facts"] = ansible_facts + + return self.return_response(module=module, + results=results, + msg=rc_data.get("msg", "NULL"), + good_codes=rc_data.get("good_codes", (0,)), + stop_on_fail=rc_data.get("stop_on_fail", True), + stop_on_success=rc_data.get("stop_on_success", False), + skipped=rc_data.get("skipped", False), + changed=rc_data.get("changed", False), + changed_if_success=rc_data.get("changed_if_success", False), + unreachable=rc_data.get("unreachable", False), + failed=rc_data.get("failed", False), + success=rc_data.get("success", False), + ansible_facts=rc_data.get("ansible_facts", dict())) + + @staticmethod + def return_response(module, results, msg="NULL", good_codes=(0,), + stop_on_fail=True, stop_on_success=False, skipped=False, + changed=False, unreachable=False, failed=False, success=False, changed_if_success=True, + ansible_facts=()): + """ + This function controls the logout and error reporting after an method or function runs. The exit_json for + ansible comes from logic within this function. If this function returns just the msg, it means to continue + execution on the playbook. It is called from the ansible module, or from the self.govern_response function. + + :param module: The Ansible Module CLASS object, used to run fail/exit json + :type module: object + :param msg: An overridable custom message from the module that called this. + :type msg: string + :param results: A dictionary object containing an API call results + :type results: dict + :param good_codes: A list of exit codes considered successful from FortiManager + :type good_codes: list + :param stop_on_fail: If true, stops playbook run when return code is NOT IN good codes (default: true) + :type stop_on_fail: boolean + :param stop_on_success: If true, stops playbook run when return code is IN good codes (default: false) + :type stop_on_success: boolean + :param changed: If True, tells Ansible that object was changed (default: false) + :type skipped: boolean + :param skipped: If True, tells Ansible that object was skipped (default: false) + :type skipped: boolean + :param unreachable: If True, tells Ansible that object was unreachable (default: false) + :type unreachable: boolean + :param failed: If True, tells Ansible that execution was a failure. Overrides good_codes. (default: false) + :type unreachable: boolean + :param success: If True, tells Ansible that execution was a success. Overrides good_codes. (default: false) + :type unreachable: boolean + :param changed_if_success: If True, defaults to changed if successful if you specify or not" + :type changed_if_success: boolean + :param ansible_facts: A prepared dictionary of ansible facts from the execution. + :type ansible_facts: dict + + :return: A string object that contains an error message + :rtype: str + """ + + # VALIDATION ERROR + if (len(results) == 0) or (failed and success) or (changed and unreachable): + module.exit_json(msg="Handle_response was called with no results, or conflicting failed/success or " + "changed/unreachable parameters. Fix the exit code on module. " + "Generic Failure", failed=True) + + # IDENTIFY SUCCESS/FAIL IF NOT DEFINED + if not failed and not success: + if len(results) > 0: + if results[0] not in good_codes: + failed = True + elif results[0] in good_codes: + success = True + + if len(results) > 0: + # IF NO MESSAGE WAS SUPPLIED, GET IT FROM THE RESULTS, IF THAT DOESN'T WORK, THEN WRITE AN ERROR MESSAGE + if msg == "NULL": + try: + msg = results[1]['status']['message'] + except BaseException: + msg = "No status message returned at results[1][status][message], " \ + "and none supplied to msg parameter for handle_response." + + if failed: + # BECAUSE SKIPPED/FAILED WILL OFTEN OCCUR ON CODES THAT DON'T GET INCLUDED, THEY ARE CONSIDERED FAILURES + # HOWEVER, THEY ARE MUTUALLY EXCLUSIVE, SO IF IT IS MARKED SKIPPED OR UNREACHABLE BY THE MODULE LOGIC + # THEN REMOVE THE FAILED FLAG SO IT DOESN'T OVERRIDE THE DESIRED STATUS OF SKIPPED OR UNREACHABLE. + if failed and skipped: + failed = False + if failed and unreachable: + failed = False + if stop_on_fail: + module.exit_json(msg=msg, failed=failed, changed=changed, unreachable=unreachable, skipped=skipped, + results=results[1], ansible_facts=ansible_facts, rc=results[0], + invocation={"module_args": ansible_facts["ansible_params"]}) + elif success: + if changed_if_success: + changed = True + success = False + if stop_on_success: + module.exit_json(msg=msg, success=success, changed=changed, unreachable=unreachable, + skipped=skipped, results=results[1], ansible_facts=ansible_facts, rc=results[0], + invocation={"module_args": ansible_facts["ansible_params"]}) + return msg + + def construct_ansible_facts(self, response, ansible_params, paramgram, *args, **kwargs): + """ + Constructs a dictionary to return to ansible facts, containing various information about the execution. + + :param response: Contains the response from the FortiManager. + :type response: dict + :param ansible_params: Contains the parameters Ansible was called with. + :type ansible_params: dict + :param paramgram: Contains the paramgram passed to the modules' local modify function. + :type paramgram: dict + :param args: Free-form arguments that could be added. + :param kwargs: Free-form keyword arguments that could be added. + + :return: A dictionary containing lots of information to append to Ansible Facts. + :rtype: dict + """ + + facts = { + "response": response, + "ansible_params": scrub_dict(ansible_params), + "paramgram": scrub_dict(paramgram), + "connected_fmgr": self._conn.return_connected_fmgr() + } + + if args: + facts["custom_args"] = args + if kwargs: + facts.update(kwargs) + + return facts + + +########################## +# BEGIN DEPRECATED METHODS +########################## + +# SOME OF THIS CODE IS DUPLICATED IN THE PLUGIN, BUT THOSE ARE PLUGIN SPECIFIC. THIS VERSION STILL ALLOWS FOR +# THE USAGE OF PYFMG FOR CUSTOMERS WHO HAVE NOT YET UPGRADED TO ANSIBLE 2.7 + +# LEGACY PYFMG METHODS START +# USED TO DETERMINE LOCK CONTEXT ON A FORTIMANAGER. A DATABASE LOCKING CONCEPT THAT NEEDS TO BE ACCOUNTED FOR. + +class FMGLockContext(object): + """ + - DEPRECATING: USING CONNECTION MANAGER NOW INSTEAD. EVENTUALLY THIS CLASS WILL DISAPPEAR. PLEASE + - CONVERT ALL MODULES TO CONNECTION MANAGER METHOD. + - LEGACY pyFMG HANDLER OBJECT: REQUIRES A CHECK FOR PY FMG AT TOP OF PAGE + """ + def __init__(self, fmg): + self._fmg = fmg + self._locked_adom_list = list() + self._uses_workspace = False + self._uses_adoms = False + + @property + def uses_workspace(self): + return self._uses_workspace + + @uses_workspace.setter + def uses_workspace(self, val): + self._uses_workspace = val + + @property + def uses_adoms(self): + return self._uses_adoms + + @uses_adoms.setter + def uses_adoms(self, val): + self._uses_adoms = val + + def add_adom_to_lock_list(self, adom): + if adom not in self._locked_adom_list: + self._locked_adom_list.append(adom) + + def remove_adom_from_lock_list(self, adom): + if adom in self._locked_adom_list: + self._locked_adom_list.remove(adom) + + def check_mode(self): + url = "/cli/global/system/global" + code, resp_obj = self._fmg.get(url, fields=["workspace-mode", "adom-status"]) + try: + if resp_obj["workspace-mode"] != 0: + self.uses_workspace = True + except KeyError: + self.uses_workspace = False + try: + if resp_obj["adom-status"] == 1: + self.uses_adoms = True + except KeyError: + self.uses_adoms = False + + def run_unlock(self): + for adom_locked in self._locked_adom_list: + self.unlock_adom(adom_locked) + + def lock_adom(self, adom=None, *args, **kwargs): + if adom: + if adom.lower() == "global": + url = "/dvmdb/global/workspace/lock/" + else: + url = "/dvmdb/adom/{adom}/workspace/lock/".format(adom=adom) + else: + url = "/dvmdb/adom/root/workspace/lock" + code, respobj = self._fmg.execute(url, {}, *args, **kwargs) + if code == 0 and respobj["status"]["message"].lower() == "ok": + self.add_adom_to_lock_list(adom) + return code, respobj + + def unlock_adom(self, adom=None, *args, **kwargs): + if adom: + if adom.lower() == "global": + url = "/dvmdb/global/workspace/unlock/" + else: + url = "/dvmdb/adom/{adom}/workspace/unlock/".format(adom=adom) + else: + url = "/dvmdb/adom/root/workspace/unlock" + code, respobj = self._fmg.execute(url, {}, *args, **kwargs) + if code == 0 and respobj["status"]["message"].lower() == "ok": + self.remove_adom_from_lock_list(adom) + return code, respobj + + def commit_changes(self, adom=None, aux=False, *args, **kwargs): + if adom: + if aux: + url = "/pm/config/adom/{adom}/workspace/commit".format(adom=adom) + else: + if adom.lower() == "global": + url = "/dvmdb/global/workspace/commit/" + else: + url = "/dvmdb/adom/{adom}/workspace/commit".format(adom=adom) + else: + url = "/dvmdb/adom/root/workspace/commit" + return self._fmg.execute(url, {}, *args, **kwargs) + + +# DEPRECATED -- USE PLUGIN INSTEAD class AnsibleFortiManager(object): + """ + - DEPRECATING: USING CONNECTION MANAGER NOW INSTEAD. EVENTUALLY THIS CLASS WILL DISAPPEAR. PLEASE + - CONVERT ALL MODULES TO CONNECTION MANAGER METHOD. + - LEGACY pyFMG HANDLER OBJECT: REQUIRES A CHECK FOR PY FMG AT TOP OF PAGE + """ def __init__(self, module, ip=None, username=None, passwd=None, use_ssl=True, verify_ssl=False, timeout=300): self.ip = ip @@ -85,3 +455,7 @@ class AnsibleFortiManager(object): def clone(self, url, data): return self.fmgr_instance.clone(url, **data) + +########################## +# END DEPRECATED METHODS +########################## diff --git a/lib/ansible/plugins/httpapi/fortimanager.py b/lib/ansible/plugins/httpapi/fortimanager.py new file mode 100644 index 0000000000..c8f793a70e --- /dev/null +++ b/lib/ansible/plugins/httpapi/fortimanager.py @@ -0,0 +1,355 @@ +# Copyright (c) 2018 Fortinet and/or its affiliates. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = """ +--- +author: + - Luke Weighall (@lweighall) + - Andrew Welsh (@Ghilli3) + - Jim Huber (@p4r4n0y1ng) +httpapi : fortimanager +short_description: HttpApi Plugin for Fortinet FortiManager Appliance or VM +description: + - This HttpApi plugin provides methods to connect to Fortinet FortiManager Appliance or VM via JSON RPC API +version_added: "2.8" + +""" + +import json +from ansible.plugins.httpapi import HttpApiBase +from ansible.module_utils.basic import to_text +from ansible.module_utils.network.fortimanager.common import BASE_HEADERS +from ansible.module_utils.network.fortimanager.common import FMGBaseException +from ansible.module_utils.network.fortimanager.common import FMGRCommon +from ansible.module_utils.network.fortimanager.common import FMGRMethods + + +class HttpApi(HttpApiBase): + def __init__(self, connection): + super(HttpApi, self).__init__(connection) + self._req_id = 0 + self._sid = None + self._url = "/jsonrpc" + self._host = None + self._tools = FMGRCommon + self._debug = False + self._connected_fmgr = None + self._last_response_msg = None + self._last_response_code = None + self._last_data_payload = None + self._last_url = None + self._last_response_raw = None + self._locked_adom_list = list() + self._uses_workspace = False + self._uses_adoms = False + + def set_become(self, become_context): + """ + ELEVATION IS NOT REQUIRED ON FORTINET DEVICES - SKIPPED + :param become_context: Unused input. + :return: None + """ + return None + + def update_auth(self, response, response_data): + """ + TOKENS ARE NOT USED SO NO NEED TO UPDATE AUTH + :param response: Unused input. + :param response_data Unused_input. + :return: None + """ + return None + + def login(self, username, password): + """ + This function will log the plugin into FortiManager, and return the results. + :param username: Username of FortiManager Admin + :param password: Password of FortiManager Admin + + :return: Dictionary of status, if it logged in or not. + """ + self.send_request(FMGRMethods.EXEC, self._tools.format_request(FMGRMethods.EXEC, "sys/login/user", + passwd=password, user=username,)) + + if "FortiManager object connected to FortiManager" in self.__str__(): + # If Login worked, then inspect the FortiManager for Workspace Mode, and it's system information. + self.inspect_fmgr() + return + else: + raise FMGBaseException(msg="Unknown error while logging in...connection was lost during login operation...." + " Exiting") + + def inspect_fmgr(self): + # CHECK FOR WORKSPACE MODE TO SEE IF WE HAVE TO ENABLE ADOM LOCKS + + self.check_mode() + # CHECK FOR SYSTEM STATUS -- SHOULD RETURN 0 + status = self.get_system_status() + if status[0] == -11: + # THE CONNECTION GOT LOST SOMEHOW, REMOVE THE SID AND REPORT BAD LOGIN + self.logout() + raise FMGBaseException(msg="Error -11 -- the Session ID was likely malformed somehow. Contact authors." + " Exiting") + elif status[0] == 0: + try: + self._connected_fmgr = status[1] + self._host = self._connected_fmgr["Hostname"] + except BaseException: + pass + return + + def logout(self): + """ + This function will logout of the FortiManager. + """ + if self.sid is not None: + if self.uses_workspace: + self.run_unlock() + ret_code, response = self.send_request(FMGRMethods.EXEC, + self._tools.format_request(FMGRMethods.EXEC, "sys/logout")) + self.sid = None + return ret_code, response + + def send_request(self, method, params): + """ + Responsible for actual sending of data to the connection httpapi base plugin. Does some formatting as well. + :param params: A formatted dictionary that was returned by self.common_datagram_params() + before being called here. + :param method: The preferred API Request method (GET, ADD, POST, etc....) + :type method: basestring + + :return: Dictionary of status, if it logged in or not. + """ + + try: + if self.sid is None and params[0]["url"] != "sys/login/user": + raise FMGBaseException("An attempt was made to login with the SID None and URL != login url.") + except IndexError: + raise FMGBaseException("An attempt was made at communicating with a FMG with " + "no valid session and an incorrectly formatted request.") + except Exception: + raise FMGBaseException("An attempt was made at communicating with a FMG with " + "no valid session and an unexpected error was discovered.") + + self._update_request_id() + json_request = { + "method": method, + "params": params, + "session": self.sid, + "id": self.req_id, + "verbose": 1 + } + data = json.dumps(json_request, ensure_ascii=False).replace('\\\\', '\\') + try: + # Sending URL and Data in Unicode, per Ansible Specifications for Connection Plugins + response, response_data = self.connection.send(path=to_text(self._url), data=to_text(data), + headers=BASE_HEADERS) + # Get Unicode Response - Must convert from StringIO to unicode first so we can do a replace function below + result = json.loads(to_text(response_data.getvalue())) + self._update_self_from_response(result, self._url, data) + return self._handle_response(result) + except Exception as err: + raise FMGBaseException(err) + + def _handle_response(self, response): + self._set_sid(response) + if isinstance(response["result"], list): + result = response["result"][0] + else: + result = response["result"] + if "data" in result: + return result["status"]["code"], result["data"] + else: + return result["status"]["code"], result + + def _update_self_from_response(self, response, url, data): + self._last_response_raw = response + if isinstance(response["result"], list): + result = response["result"][0] + else: + result = response["result"] + if "status" in result: + self._last_response_code = result["status"]["code"] + self._last_response_msg = result["status"]["message"] + self._last_url = url + self._last_data_payload = data + + def _set_sid(self, response): + if self.sid is None and "session" in response: + self.sid = response["session"] + + def return_connected_fmgr(self): + """ + Returns the data stored under self._connected_fmgr + + :return: dict + """ + try: + if self._connected_fmgr: + return self._connected_fmgr + except BaseException: + raise FMGBaseException("Couldn't Retrieve Connected FMGR Stats") + + def get_system_status(self): + """ + Returns the system status page from the FortiManager, for logging and other uses. + return: status + """ + status = self.send_request(FMGRMethods.GET, self._tools.format_request(FMGRMethods.GET, "sys/status")) + return status + + @property + def debug(self): + return self._debug + + @debug.setter + def debug(self, val): + self._debug = val + + @property + def req_id(self): + return self._req_id + + @req_id.setter + def req_id(self, val): + self._req_id = val + + def _update_request_id(self, reqid=0): + self.req_id = reqid if reqid != 0 else self.req_id + 1 + + @property + def sid(self): + return self._sid + + @sid.setter + def sid(self, val): + self._sid = val + + def __str__(self): + if self.sid is not None and self.connection._url is not None: + return "FortiManager object connected to FortiManager: " + str(self.connection._url) + return "FortiManager object with no valid connection to a FortiManager appliance." + + ################################## + # BEGIN DATABASE LOCK CONTEXT CODE + ################################## + + @property + def uses_workspace(self): + return self._uses_workspace + + @uses_workspace.setter + def uses_workspace(self, val): + self._uses_workspace = val + + @property + def uses_adoms(self): + return self._uses_adoms + + @uses_adoms.setter + def uses_adoms(self, val): + self._uses_adoms = val + + def add_adom_to_lock_list(self, adom): + if adom not in self._locked_adom_list: + self._locked_adom_list.append(adom) + + def remove_adom_from_lock_list(self, adom): + if adom in self._locked_adom_list: + self._locked_adom_list.remove(adom) + + def check_mode(self): + """ + Checks FortiManager for the use of Workspace mode + """ + url = "/cli/global/system/global" + code, resp_obj = self.send_request(FMGRMethods.GET, self._tools.format_request(FMGRMethods.GET, url, + fields=["workspace-mode", + "adom-status"])) + try: + if resp_obj["workspace-mode"] != 0: + self.uses_workspace = True + except KeyError: + self.uses_workspace = False + try: + if resp_obj["adom-status"] == 1: + self.uses_adoms = True + except KeyError: + self.uses_adoms = False + + def run_unlock(self): + """ + Checks for ADOM status, if locked, it will unlock + """ + for adom_locked in self._locked_adom_list: + self.unlock_adom(adom_locked) + + def lock_adom(self, adom=None, *args, **kwargs): + """ + Locks an ADOM for changes + """ + if adom: + if adom.lower() == "global": + url = "/dvmdb/global/workspace/lock/" + else: + url = "/dvmdb/adom/{adom}/workspace/lock/".format(adom=adom) + else: + url = "/dvmdb/adom/root/workspace/lock" + code, respobj = self.send_request(FMGRMethods.EXEC, self._tools.format_request(FMGRMethods.EXEC, url)) + if code == 0 and respobj["status"]["message"].lower() == "ok": + self.add_adom_to_lock_list(adom) + return code, respobj + + def unlock_adom(self, adom=None, *args, **kwargs): + """ + Unlocks an ADOM after changes + """ + if adom: + if adom.lower() == "global": + url = "/dvmdb/global/workspace/unlock/" + else: + url = "/dvmdb/adom/{adom}/workspace/unlock/".format(adom=adom) + else: + url = "/dvmdb/adom/root/workspace/unlock" + code, respobj = self.send_request(FMGRMethods.EXEC, self._tools.format_request(FMGRMethods.EXEC, url)) + if code == 0 and respobj["status"]["message"].lower() == "ok": + self.remove_adom_from_lock_list(adom) + return code, respobj + + def commit_changes(self, adom=None, aux=False, *args, **kwargs): + """ + Commits changes to an ADOM + """ + if adom: + if aux: + url = "/pm/config/adom/{adom}/workspace/commit".format(adom=adom) + else: + if adom.lower() == "global": + url = "/dvmdb/global/workspace/commit/" + else: + url = "/dvmdb/adom/{adom}/workspace/commit".format(adom=adom) + else: + url = "/dvmdb/adom/root/workspace/commit" + return self.send_request(FMGRMethods.EXEC, self._tools.format_request(FMGRMethods.EXEC, url)) + + ################################ + # END DATABASE LOCK CONTEXT CODE + ################################