1# Copyright (c) 2018 Fortinet and/or its affiliates.
2#
3# This file is part of Ansible
4#
5# Ansible is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Ansible is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
17#
18
19from __future__ import (absolute_import, division, print_function)
20__metaclass__ = type
21
22DOCUMENTATION = """
23---
24author:
25    - Luke Weighall (@lweighall)
26    - Andrew Welsh (@Ghilli3)
27    - Jim Huber (@p4r4n0y1ng)
28httpapi : fortianalyzer
29short_description: HttpApi Plugin for Fortinet FortiAnalyzer Appliance or VM
30description:
31  - This HttpApi plugin provides methods to connect to Fortinet FortiAnalyzer Appliance or VM via JSON RPC API
32version_added: "2.9"
33
34"""
35
36import json
37from ansible.plugins.httpapi import HttpApiBase
38from ansible.module_utils.basic import to_text
39from ansible.module_utils.network.fortianalyzer.common import BASE_HEADERS
40from ansible.module_utils.network.fortianalyzer.common import FAZBaseException
41from ansible.module_utils.network.fortianalyzer.common import FAZCommon
42from ansible.module_utils.network.fortianalyzer.common import FAZMethods
43
44
45class HttpApi(HttpApiBase):
46    def __init__(self, connection):
47        super(HttpApi, self).__init__(connection)
48        self._req_id = 0
49        self._sid = None
50        self._url = "/jsonrpc"
51        self._host = None
52        self._tools = FAZCommon
53        self._debug = False
54        self._connected_faz = None
55        self._last_response_msg = None
56        self._last_response_code = None
57        self._last_data_payload = None
58        self._last_url = None
59        self._last_response_raw = None
60        self._locked_adom_list = list()
61        self._locked_adoms_by_user = list()
62        self._uses_workspace = False
63        self._uses_adoms = False
64        self._adom_list = list()
65        self._logged_in_user = None
66
67    def set_become(self, become_context):
68        """
69        ELEVATION IS NOT REQUIRED ON FORTINET DEVICES - SKIPPED
70        :param become_context: Unused input.
71        :return: None
72        """
73        return None
74
75    def update_auth(self, response, response_data):
76        """
77        TOKENS ARE NOT USED SO NO NEED TO UPDATE AUTH
78        :param response: Unused input.
79        :param response_data Unused_input.
80        :return: None
81        """
82        return None
83
84    def login(self, username, password):
85        """
86        This function will log the plugin into FortiAnalyzer, and return the results.
87        :param username: Username of FortiAnalyzer Admin
88        :param password: Password of FortiAnalyzer Admin
89
90        :return: Dictionary of status, if it logged in or not.
91        """
92
93        self._logged_in_user = username
94        self.send_request(FAZMethods.EXEC, self._tools.format_request(FAZMethods.EXEC, "sys/login/user",
95                                                                      passwd=password, user=username,))
96
97        if "FortiAnalyzer object connected to FortiAnalyzer" in self.__str__():
98            # If Login worked, then inspect the FortiAnalyzer for Workspace Mode, and it's system information.
99            self.inspect_faz()
100            return
101        else:
102            raise FAZBaseException(msg="Unknown error while logging in...connection was lost during login operation...."
103                                       " Exiting")
104
105    def inspect_faz(self):
106        # CHECK FOR WORKSPACE MODE TO SEE IF WE HAVE TO ENABLE ADOM LOCKS
107        status = self.get_system_status()
108        if status[0] == -11:
109            # THE CONNECTION GOT LOST SOMEHOW, REMOVE THE SID AND REPORT BAD LOGIN
110            self.logout()
111            raise FAZBaseException(msg="Error -11 -- the Session ID was likely malformed somehow. Contact authors."
112                                       " Exiting")
113        elif status[0] == 0:
114            try:
115                self.check_mode()
116                if self._uses_adoms:
117                    self.get_adom_list()
118                if self._uses_workspace:
119                    self.get_locked_adom_list()
120                self._connected_faz = status[1]
121                self._host = self._connected_faz["Hostname"]
122            except BaseException:
123                pass
124        return
125
126    def logout(self):
127        """
128        This function will logout of the FortiAnalyzer.
129        """
130        if self.sid is not None:
131            # IF WE WERE USING WORKSPACES, THEN CLEAN UP OUR LOCKS IF THEY STILL EXIST
132            if self.uses_workspace:
133                self.get_lock_info()
134                self.run_unlock()
135            ret_code, response = self.send_request(FAZMethods.EXEC,
136                                                   self._tools.format_request(FAZMethods.EXEC, "sys/logout"))
137            self.sid = None
138            return ret_code, response
139
140    def send_request(self, method, params):
141        """
142        Responsible for actual sending of data to the connection httpapi base plugin. Does some formatting as well.
143        :param params: A formatted dictionary that was returned by self.common_datagram_params()
144        before being called here.
145        :param method: The preferred API Request method (GET, ADD, POST, etc....)
146        :type method: basestring
147
148        :return: Dictionary of status, if it logged in or not.
149        """
150
151        try:
152            if self.sid is None and params[0]["url"] != "sys/login/user":
153                raise FAZBaseException("An attempt was made to login with the SID None and URL != login url.")
154        except IndexError:
155            raise FAZBaseException("An attempt was made at communicating with a FAZ with "
156                                   "no valid session and an incorrectly formatted request.")
157        except Exception:
158            raise FAZBaseException("An attempt was made at communicating with a FAZ with "
159                                   "no valid session and an unexpected error was discovered.")
160
161        self._update_request_id()
162        json_request = {
163            "method": method,
164            "params": params,
165            "session": self.sid,
166            "id": self.req_id,
167            "verbose": 1
168        }
169        data = json.dumps(json_request, ensure_ascii=False).replace('\\\\', '\\')
170        try:
171            # Sending URL and Data in Unicode, per Ansible Specifications for Connection Plugins
172            response, response_data = self.connection.send(path=to_text(self._url), data=to_text(data),
173                                                           headers=BASE_HEADERS)
174            # Get Unicode Response - Must convert from StringIO to unicode first so we can do a replace function below
175            result = json.loads(to_text(response_data.getvalue()))
176            self._update_self_from_response(result, self._url, data)
177            return self._handle_response(result)
178        except Exception as err:
179            raise FAZBaseException(err)
180
181    def _handle_response(self, response):
182        self._set_sid(response)
183        if isinstance(response["result"], list):
184            result = response["result"][0]
185        else:
186            result = response["result"]
187        if "data" in result:
188            return result["status"]["code"], result["data"]
189        else:
190            return result["status"]["code"], result
191
192    def _update_self_from_response(self, response, url, data):
193        self._last_response_raw = response
194        if isinstance(response["result"], list):
195            result = response["result"][0]
196        else:
197            result = response["result"]
198        if "status" in result:
199            self._last_response_code = result["status"]["code"]
200            self._last_response_msg = result["status"]["message"]
201            self._last_url = url
202            self._last_data_payload = data
203
204    def _set_sid(self, response):
205        if self.sid is None and "session" in response:
206            self.sid = response["session"]
207
208    def return_connected_faz(self):
209        """
210        Returns the data stored under self._connected_faz
211
212        :return: dict
213        """
214        try:
215            if self._connected_faz:
216                return self._connected_faz
217        except BaseException:
218            raise FAZBaseException("Couldn't Retrieve Connected FAZ Stats")
219
220    def get_system_status(self):
221        """
222        Returns the system status page from the FortiAnalyzer, for logging and other uses.
223        return: status
224        """
225        status = self.send_request(FAZMethods.GET, self._tools.format_request(FAZMethods.GET, "sys/status"))
226        return status
227
228    @property
229    def debug(self):
230        return self._debug
231
232    @debug.setter
233    def debug(self, val):
234        self._debug = val
235
236    @property
237    def req_id(self):
238        return self._req_id
239
240    @req_id.setter
241    def req_id(self, val):
242        self._req_id = val
243
244    def _update_request_id(self, reqid=0):
245        self.req_id = reqid if reqid != 0 else self.req_id + 1
246
247    @property
248    def sid(self):
249        return self._sid
250
251    @sid.setter
252    def sid(self, val):
253        self._sid = val
254
255    def __str__(self):
256        if self.sid is not None and self.connection._url is not None:
257            return "FortiAnalyzer object connected to FortiAnalyzer: " + str(self.connection._url)
258        return "FortiAnalyzer object with no valid connection to a FortiAnalyzer appliance."
259
260    ##################################
261    # BEGIN DATABASE LOCK CONTEXT CODE
262    ##################################
263
264    @property
265    def uses_workspace(self):
266        return self._uses_workspace
267
268    @uses_workspace.setter
269    def uses_workspace(self, val):
270        self._uses_workspace = val
271
272    @property
273    def uses_adoms(self):
274        return self._uses_adoms
275
276    @uses_adoms.setter
277    def uses_adoms(self, val):
278        self._uses_adoms = val
279
280    def add_adom_to_lock_list(self, adom):
281        if adom not in self._locked_adom_list:
282            self._locked_adom_list.append(adom)
283
284    def remove_adom_from_lock_list(self, adom):
285        if adom in self._locked_adom_list:
286            self._locked_adom_list.remove(adom)
287
288    def check_mode(self):
289        """
290        Checks FortiAnalyzer for the use of Workspace mode
291        """
292        url = "/cli/global/system/global"
293        code, resp_obj = self.send_request(FAZMethods.GET,
294                                           self._tools.format_request(FAZMethods.GET,
295                                                                      url,
296                                                                      fields=["workspace-mode", "adom-status"]))
297        try:
298            if resp_obj["workspace-mode"] == "workflow":
299                self.uses_workspace = True
300            elif resp_obj["workspace-mode"] == "disabled":
301                self.uses_workspace = False
302        except KeyError:
303            self.uses_workspace = False
304        except BaseException:
305            raise FAZBaseException(msg="Couldn't determine workspace-mode in the plugin")
306        try:
307            if resp_obj["adom-status"] in [1, "enable"]:
308                self.uses_adoms = True
309            else:
310                self.uses_adoms = False
311        except KeyError:
312            self.uses_adoms = False
313        except BaseException:
314            raise FAZBaseException(msg="Couldn't determine adom-status in the plugin")
315
316    def run_unlock(self):
317        """
318        Checks for ADOM status, if locked, it will unlock
319        """
320        for adom_locked in self._locked_adoms_by_user:
321            adom = adom_locked["adom"]
322            self.unlock_adom(adom)
323
324    def lock_adom(self, adom=None, *args, **kwargs):
325        """
326        Locks an ADOM for changes
327        """
328        if adom:
329            if adom.lower() == "global":
330                url = "/dvmdb/global/workspace/lock/"
331            else:
332                url = "/dvmdb/adom/{adom}/workspace/lock/".format(adom=adom)
333        else:
334            url = "/dvmdb/adom/root/workspace/lock"
335        code, respobj = self.send_request(FAZMethods.EXEC, self._tools.format_request(FAZMethods.EXEC, url))
336        if code == 0 and respobj["status"]["message"].lower() == "ok":
337            self.add_adom_to_lock_list(adom)
338        return code, respobj
339
340    def unlock_adom(self, adom=None, *args, **kwargs):
341        """
342        Unlocks an ADOM after changes
343        """
344        if adom:
345            if adom.lower() == "global":
346                url = "/dvmdb/global/workspace/unlock/"
347            else:
348                url = "/dvmdb/adom/{adom}/workspace/unlock/".format(adom=adom)
349        else:
350            url = "/dvmdb/adom/root/workspace/unlock"
351        code, respobj = self.send_request(FAZMethods.EXEC, self._tools.format_request(FAZMethods.EXEC, url))
352        if code == 0 and respobj["status"]["message"].lower() == "ok":
353            self.remove_adom_from_lock_list(adom)
354        return code, respobj
355
356    def commit_changes(self, adom=None, aux=False, *args, **kwargs):
357        """
358        Commits changes to an ADOM
359        """
360        if adom:
361            if aux:
362                url = "/pm/config/adom/{adom}/workspace/commit".format(adom=adom)
363            else:
364                if adom.lower() == "global":
365                    url = "/dvmdb/global/workspace/commit/"
366                else:
367                    url = "/dvmdb/adom/{adom}/workspace/commit".format(adom=adom)
368        else:
369            url = "/dvmdb/adom/root/workspace/commit"
370        return self.send_request(FAZMethods.EXEC, self._tools.format_request(FAZMethods.EXEC, url))
371
372    def get_lock_info(self, adom=None):
373        """
374        Gets ADOM lock info so it can be displayed with the error messages. Or if determined to be locked by ansible
375        for some reason, then unlock it.
376        """
377        if not adom or adom == "root":
378            url = "/dvmdb/adom/root/workspace/lockinfo"
379        else:
380            if adom.lower() == "global":
381                url = "/dvmdb/global/workspace/lockinfo/"
382            else:
383                url = "/dvmdb/adom/{adom}/workspace/lockinfo/".format(adom=adom)
384        datagram = {}
385        data = self._tools.format_request(FAZMethods.GET, url, **datagram)
386        resp_obj = self.send_request(FAZMethods.GET, data)
387        code = resp_obj[0]
388        if code != 0:
389            self._module.fail_json(msg=("An error occurred trying to get the ADOM Lock Info. Error: " + str(resp_obj)))
390        elif code == 0:
391            try:
392                if resp_obj[1]["status"]["message"] == "OK":
393                    self._lock_info = None
394            except BaseException:
395                self._lock_info = resp_obj[1]
396        return resp_obj
397
398    def get_adom_list(self):
399        """
400        Gets the list of ADOMs for the FortiAnalyzer
401        """
402        if self.uses_adoms:
403            url = "/dvmdb/adom"
404            datagram = {}
405            data = self._tools.format_request(FAZMethods.GET, url, **datagram)
406            resp_obj = self.send_request(FAZMethods.GET, data)
407            code = resp_obj[0]
408            if code != 0:
409                self._module.fail_json(msg=("An error occurred trying to get the ADOM Info. Error: " + str(resp_obj)))
410            elif code == 0:
411                num_of_adoms = len(resp_obj[1])
412                append_list = ['root', ]
413                for adom in resp_obj[1]:
414                    if adom["tab_status"] != "":
415                        append_list.append(str(adom["name"]))
416                self._adom_list = append_list
417            return resp_obj
418
419    def get_locked_adom_list(self):
420        """
421        Gets the list of locked adoms
422        """
423        try:
424            locked_list = list()
425            locked_by_user_list = list()
426            for adom in self._adom_list:
427                adom_lock_info = self.get_lock_info(adom=adom)
428                try:
429                    if adom_lock_info[1]["status"]["message"] == "OK":
430                        continue
431                except BaseException:
432                    pass
433                try:
434                    if adom_lock_info[1][0]["lock_user"]:
435                        locked_list.append(str(adom))
436                    if adom_lock_info[1][0]["lock_user"] == self._logged_in_user:
437                        locked_by_user_list.append({"adom": str(adom), "user": str(adom_lock_info[1][0]["lock_user"])})
438                except BaseException as err:
439                    raise FAZBaseException(err)
440            self._locked_adom_list = locked_list
441            self._locked_adoms_by_user = locked_by_user_list
442
443        except BaseException as err:
444            raise FAZBaseException(msg=("An error occurred while trying to get the locked adom list. Error: "
445                                        + str(err)))
446
447    ################################
448    # END DATABASE LOCK CONTEXT CODE
449    ################################
450