1# Copyright (c) 2018 Cisco and/or its affiliates.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""
16Util functions for the NXOS modules.
17"""
18
19import collections
20import http.client
21import json
22import logging
23import os
24import re
25import socket
26from collections.abc import Iterable
27
28import salt.utils.http
29from salt.exceptions import (
30    CommandExecutionError,
31    NxosClientError,
32    NxosError,
33    NxosRequestNotSupported,
34)
35from salt.utils.args import clean_kwargs
36
37log = logging.getLogger(__name__)
38
39
40class UHTTPConnection(http.client.HTTPConnection):
41    """
42    Subclass of Python library HTTPConnection that uses a unix-domain socket.
43    """
44
45    def __init__(self, path):
46        http.client.HTTPConnection.__init__(self, "localhost")
47        self.path = path
48
49    def connect(self):
50        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
51        sock.connect(self.path)
52        self.sock = sock
53
54
55class NxapiClient:
56    """
57    Class representing an NX-API client that connects over http(s) or
58    unix domain socket (UDS).
59    """
60
61    # Location of unix domain socket for NX-API localhost
62    NXAPI_UDS = "/tmp/nginx_local/nginx_1_be_nxapi.sock"
63    # NXAPI listens for remote connections to "http(s)://<switch IP>/ins"
64    # NXAPI listens for local connections to "http(s)://<UDS>/ins_local"
65    NXAPI_REMOTE_URI_PATH = "/ins"
66    NXAPI_UDS_URI_PATH = "/ins_local"
67    NXAPI_VERSION = "1.0"
68
69    def __init__(self, **nxos_kwargs):
70        """
71        Initialize NxapiClient() connection object.  By default this connects
72        to the local unix domain socket (UDS).  If http(s) is required to
73        connect to a remote device then
74            nxos_kwargs['host'],
75            nxos_kwargs['username'],
76            nxos_kwargs['password'],
77            nxos_kwargs['transport'],
78            nxos_kwargs['port'],
79        parameters must be provided.
80        """
81        self.nxargs = self._prepare_conn_args(clean_kwargs(**nxos_kwargs))
82        # Default: Connect to unix domain socket on localhost.
83        if self.nxargs["connect_over_uds"]:
84            if not os.path.exists(self.NXAPI_UDS):
85                raise NxosClientError(
86                    "No host specified and no UDS found at {}\n".format(self.NXAPI_UDS)
87                )
88
89            # Create UHTTPConnection object for NX-API communication over UDS.
90            log.info("Nxapi connection arguments: %s", self.nxargs)
91            log.info("Connecting over unix domain socket")
92            self.connection = UHTTPConnection(self.NXAPI_UDS)
93        else:
94            # Remote connection - Proxy Minion, connect over http(s)
95            log.info("Nxapi connection arguments: %s", self.nxargs)
96            log.info("Connecting over %s", self.nxargs["transport"])
97            self.connection = salt.utils.http.query
98
99    def _use_remote_connection(self, kwargs):
100        """
101        Determine if connection is local or remote
102        """
103        kwargs["host"] = kwargs.get("host")
104        kwargs["username"] = kwargs.get("username")
105        kwargs["password"] = kwargs.get("password")
106        if (
107            kwargs["host"] is None
108            or kwargs["username"] is None
109            or kwargs["password"] is None
110        ):
111            return False
112        else:
113            return True
114
115    def _prepare_conn_args(self, kwargs):
116        """
117        Set connection arguments for remote or local connection.
118        """
119        kwargs["connect_over_uds"] = True
120        kwargs["timeout"] = kwargs.get("timeout", 60)
121        kwargs["cookie"] = kwargs.get("cookie", "admin")
122        if self._use_remote_connection(kwargs):
123            kwargs["transport"] = kwargs.get("transport", "https")
124            if kwargs["transport"] == "https":
125                kwargs["port"] = kwargs.get("port", 443)
126            else:
127                kwargs["port"] = kwargs.get("port", 80)
128            kwargs["verify"] = kwargs.get("verify", True)
129            if isinstance(kwargs["verify"], bool):
130                kwargs["verify_ssl"] = kwargs["verify"]
131            else:
132                kwargs["ca_bundle"] = kwargs["verify"]
133            kwargs["connect_over_uds"] = False
134        return kwargs
135
136    def _build_request(self, type, commands):
137        """
138        Build NX-API JSON request.
139        """
140        request = {}
141        headers = {
142            "content-type": "application/json",
143        }
144        if self.nxargs["connect_over_uds"]:
145            user = self.nxargs["cookie"]
146            headers["cookie"] = "nxapi_auth=" + user + ":local"
147            request["url"] = self.NXAPI_UDS_URI_PATH
148        else:
149            request["url"] = "{transport}://{host}:{port}{uri}".format(
150                transport=self.nxargs["transport"],
151                host=self.nxargs["host"],
152                port=self.nxargs["port"],
153                uri=self.NXAPI_REMOTE_URI_PATH,
154            )
155
156        if isinstance(commands, (list, set, tuple)):
157            commands = " ; ".join(commands)
158        payload = {}
159        # Some versions of NX-OS fail to process the payload properly if
160        # 'input' gets serialized before 'type' and the payload of 'input'
161        # contains the string 'type'.  Use an ordered dict to enforce ordering.
162        payload["ins_api"] = collections.OrderedDict()
163        payload["ins_api"]["version"] = self.NXAPI_VERSION
164        payload["ins_api"]["type"] = type
165        payload["ins_api"]["chunk"] = "0"
166        payload["ins_api"]["sid"] = "1"
167        payload["ins_api"]["input"] = commands
168        payload["ins_api"]["output_format"] = "json"
169
170        request["headers"] = headers
171        request["payload"] = json.dumps(payload)
172        request["opts"] = {"http_request_timeout": self.nxargs["timeout"]}
173        log.info("request: %s", request)
174        return request
175
176    def request(self, type, command_list):
177        """
178        Send NX-API JSON request to the NX-OS device.
179        """
180        req = self._build_request(type, command_list)
181        if self.nxargs["connect_over_uds"]:
182            self.connection.request("POST", req["url"], req["payload"], req["headers"])
183            response = self.connection.getresponse()
184        else:
185            response = self.connection(
186                req["url"],
187                method="POST",
188                opts=req["opts"],
189                data=req["payload"],
190                header_dict=req["headers"],
191                decode=True,
192                decode_type="json",
193                **self.nxargs
194            )
195
196        return self.parse_response(response, command_list)
197
198    def parse_response(self, response, command_list):
199        """
200        Parse NX-API JSON response from the NX-OS device.
201        """
202        # Check for 500 level NX-API Server Errors
203        if isinstance(response, Iterable) and "status" in response:
204            if int(response["status"]) >= 500:
205                raise NxosError("{}".format(response))
206            else:
207                raise NxosError("NX-API Request Not Supported: {}".format(response))
208
209        if isinstance(response, Iterable):
210            body = response["dict"]
211        else:
212            body = response
213
214        if self.nxargs["connect_over_uds"]:
215            body = json.loads(response.read().decode("utf-8"))
216
217        # Proceed with caution.  The JSON may not be complete.
218        # Don't just return body['ins_api']['outputs']['output'] directly.
219        output = body.get("ins_api")
220        if output is None:
221            raise NxosClientError("Unexpected JSON output\n{}".format(body))
222        if output.get("outputs"):
223            output = output["outputs"]
224        if output.get("output"):
225            output = output["output"]
226
227        # The result list stores results for each command that was sent to
228        # nxapi.
229        result = []
230        # Keep track of successful commands using previous_commands list so
231        # they can be displayed if a specific command fails in a chain of
232        # commands.
233        previous_commands = []
234
235        # Make sure output and command_list lists to be processed in the
236        # subesequent loop.
237        if not isinstance(output, list):
238            output = [output]
239        if not isinstance(command_list, list):
240            command_list = [command_list]
241        if len(command_list) == 1 and ";" in command_list[0]:
242            command_list = [cmd.strip() for cmd in command_list[0].split(";")]
243
244        for cmd_result, cmd in zip(output, command_list):
245            code = cmd_result.get("code")
246            msg = cmd_result.get("msg")
247            log.info("command %s:", cmd)
248            log.info("PARSE_RESPONSE: %s %s", code, msg)
249            if code == "400":
250                raise CommandExecutionError(
251                    {
252                        "rejected_input": cmd,
253                        "code": code,
254                        "message": msg,
255                        "cli_error": cmd_result.get("clierror"),
256                        "previous_commands": previous_commands,
257                    }
258                )
259            elif code == "413":
260                raise NxosRequestNotSupported("Error 413: {}".format(msg))
261            elif code != "200":
262                raise NxosError("Unknown Error: {}, Code: {}".format(msg, code))
263            else:
264                previous_commands.append(cmd)
265                result.append(cmd_result["body"])
266
267        return result
268
269
270def nxapi_request(commands, method="cli_show", **kwargs):
271    """
272    Send exec and config commands to the NX-OS device over NX-API.
273
274    commands
275        The exec or config commands to be sent.
276
277    method:
278        ``cli_show_ascii``: Return raw test or unstructured output.
279        ``cli_show``: Return structured output.
280        ``cli_conf``: Send configuration commands to the device.
281        Defaults to ``cli_show``.
282
283    transport: ``https``
284        Specifies the type of connection transport to use. Valid values for the
285        connection are ``http``, and  ``https``.
286
287    host: ``localhost``
288        The IP address or DNS host name of the device.
289
290    username: ``admin``
291        The username to pass to the device to authenticate the NX-API connection.
292
293    password
294        The password to pass to the device to authenticate the NX-API connection.
295
296    port
297        The TCP port of the endpoint for the NX-API connection. If this keyword is
298        not specified, the default value is automatically determined by the
299        transport type (``80`` for ``http``, or ``443`` for ``https``).
300
301    timeout: ``60``
302        Time in seconds to wait for the device to respond. Default: 60 seconds.
303
304    verify: ``True``
305        Either a boolean, in which case it controls whether we verify the NX-API
306        TLS certificate, or a string, in which case it must be a path to a CA bundle
307        to use. Defaults to ``True``.
308    """
309    client = NxapiClient(**kwargs)
310    return client.request(method, commands)
311
312
313def ping(**kwargs):
314    """
315    Verify connection to the NX-OS device over UDS.
316    """
317    return NxapiClient(**kwargs).nxargs["connect_over_uds"]
318
319
320# Grains Functions
321
322
323def _parser(block):
324    return re.compile("^{block}\n(?:^[ \n].*$\n?)+".format(block=block), re.MULTILINE)
325
326
327def _parse_software(data):
328    """
329    Internal helper function to parse sotware grain information.
330    """
331    ret = {"software": {}}
332    software = _parser("Software").search(data).group(0)
333    matcher = re.compile("^  ([^:]+): *([^\n]+)", re.MULTILINE)
334    for line in matcher.finditer(software):
335        key, val = line.groups()
336        ret["software"][key] = val
337    return ret["software"]
338
339
340def _parse_hardware(data):
341    """
342    Internal helper function to parse hardware grain information.
343    """
344    ret = {"hardware": {}}
345    hardware = _parser("Hardware").search(data).group(0)
346    matcher = re.compile("^  ([^:\n]+): *([^\n]+)", re.MULTILINE)
347    for line in matcher.finditer(hardware):
348        key, val = line.groups()
349        ret["hardware"][key] = val
350    return ret["hardware"]
351
352
353def _parse_plugins(data):
354    """
355    Internal helper function to parse plugin grain information.
356    """
357    ret = {"plugins": []}
358    plugins = _parser("plugin").search(data).group(0)
359    matcher = re.compile("^  (?:([^,]+), )+([^\n]+)", re.MULTILINE)
360    for line in matcher.finditer(plugins):
361        ret["plugins"].extend(line.groups())
362    return ret["plugins"]
363
364
365def version_info():
366    client = NxapiClient()
367    return client.request("cli_show_ascii", "show version")[0]
368
369
370def system_info(data):
371    """
372    Helper method to return parsed system_info
373    from the 'show version' command.
374    """
375    if not data:
376        return {}
377    info = {
378        "software": _parse_software(data),
379        "hardware": _parse_hardware(data),
380        "plugins": _parse_plugins(data),
381    }
382    return {"nxos": info}
383