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