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) 2016 Red Hat Inc.
8# (c) 2020 Cisco Systems Inc.
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.
28#
29# Intersight REST API Module
30# Author: Matthew Garrett
31# Contributors: David Soper, Chris Gascoigne, John McDonough
32
33from base64 import b64encode
34from email.utils import formatdate
35import re
36import json
37import hashlib
38from ansible.module_utils.six import iteritems
39from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode
40from ansible.module_utils.urls import fetch_url
41from ansible.module_utils.basic import env_fallback
42
43try:
44    from cryptography.hazmat.primitives import serialization, hashes
45    from cryptography.hazmat.primitives.asymmetric import padding, ec
46    from cryptography.hazmat.backends import default_backend
47    HAS_CRYPTOGRAPHY = True
48except ImportError:
49    HAS_CRYPTOGRAPHY = False
50
51intersight_argument_spec = dict(
52    api_private_key=dict(fallback=(env_fallback, ['INTERSIGHT_API_PRIVATE_KEY']), type='path', required=True, no_log=True),
53    api_uri=dict(fallback=(env_fallback, ['INTERSIGHT_API_URI']), type='str', default='https://intersight.com/api/v1'),
54    api_key_id=dict(fallback=(env_fallback, ['INTERSIGHT_API_KEY_ID']), type='str', required=True),
55    validate_certs=dict(type='bool', default=True),
56    use_proxy=dict(type='bool', default=True),
57)
58
59
60def get_sha256_digest(data):
61    """
62    Generates a SHA256 digest from a String.
63
64    :param data: data string set by user
65    :return: instance of digest object
66    """
67
68    digest = hashlib.sha256()
69    digest.update(data.encode())
70
71    return digest
72
73
74def prepare_str_to_sign(req_tgt, hdrs):
75    """
76    Concatenates Intersight headers in preparation to be signed
77
78    :param req_tgt : http method plus endpoint
79    :param hdrs: dict with header keys
80    :return: concatenated header authorization string
81    """
82    ss = ""
83    ss = ss + "(request-target): " + req_tgt + "\n"
84
85    length = len(hdrs.items())
86
87    i = 0
88    for key, value in hdrs.items():
89        ss = ss + key.lower() + ": " + value
90        if i < length - 1:
91            ss = ss + "\n"
92        i += 1
93
94    return ss
95
96
97def get_gmt_date():
98    """
99    Generated a GMT formatted Date
100
101    :return: current date
102    """
103
104    return formatdate(timeval=None, localtime=False, usegmt=True)
105
106
107def compare_lists(expected_list, actual_list):
108    if len(expected_list) != len(actual_list):
109        # mismatch if list lengths aren't equal
110        return False
111    for expected, actual in zip(expected_list, actual_list):
112        # if compare_values returns False, stop the loop and return
113        if not compare_values(expected, actual):
114            return False
115    # loop complete with all items matching
116    return True
117
118
119def compare_values(expected, actual):
120    try:
121        if isinstance(expected, list) and isinstance(actual, list):
122            return compare_lists(expected, actual)
123        for (key, value) in iteritems(expected):
124            if re.search(r'P(ass)?w(or)?d', key) or key not in actual:
125                # do not compare any password related attributes or attributes that are not in the actual resource
126                continue
127            if not compare_values(value, actual[key]):
128                return False
129        # loop complete with all items matching
130        return True
131    except (AttributeError, TypeError):
132        # if expected and actual != expected:
133        if actual != expected:
134            return False
135        return True
136
137
138class IntersightModule():
139
140    def __init__(self, module):
141        self.module = module
142        self.result = dict(changed=False)
143        if not HAS_CRYPTOGRAPHY:
144            self.module.fail_json(msg='cryptography is required for this module')
145        self.host = self.module.params['api_uri']
146        self.public_key = self.module.params['api_key_id']
147        try:
148            with open(self.module.params['api_private_key'], 'r') as f:
149                self.private_key = f.read()
150        except FileNotFoundError:
151            self.private_key = self.module.params['api_private_key']
152        self.digest_algorithm = ''
153        self.response_list = []
154
155    def get_sig_b64encode(self, data):
156        """
157        Generates a signed digest from a String
158
159        :param digest: string to be signed & hashed
160        :return: instance of digest object
161        """
162        # Python SDK code: Verify PEM Pre-Encapsulation Boundary
163        r = re.compile(r"\s*-----BEGIN (.*)-----\s+")
164        m = r.match(self.private_key)
165        if not m:
166            raise ValueError("Not a valid PEM pre boundary")
167        pem_header = m.group(1)
168        key = serialization.load_pem_private_key(self.private_key.encode(), None, default_backend())
169        if pem_header == 'RSA PRIVATE KEY':
170            sign = key.sign(data.encode(), padding.PKCS1v15(), hashes.SHA256())
171            self.digest_algorithm = 'rsa-sha256'
172        elif pem_header == 'EC PRIVATE KEY':
173            sign = key.sign(data.encode(), ec.ECDSA(hashes.SHA256()))
174            self.digest_algorithm = 'hs2019'
175        else:
176            raise Exception("Unsupported key: {0}".format(pem_header))
177
178        return b64encode(sign)
179
180    def get_auth_header(self, hdrs, signed_msg):
181        """
182        Assmebled an Intersight formatted authorization header
183
184        :param hdrs : object with header keys
185        :param signed_msg: base64 encoded sha256 hashed body
186        :return: concatenated authorization header
187        """
188
189        auth_str = "Signature"
190
191        auth_str = auth_str + " " + "keyId=\"" + self.public_key + "\"," + "algorithm=\"" + self.digest_algorithm + "\","
192
193        auth_str = auth_str + "headers=\"(request-target)"
194
195        for key, dummy in hdrs.items():
196            auth_str = auth_str + " " + key.lower()
197        auth_str = auth_str + "\""
198
199        auth_str = auth_str + "," + "signature=\"" + signed_msg.decode('ascii') + "\""
200
201        return auth_str
202
203    def get_moid_by_name(self, resource_path, target_name):
204        """
205        Retrieve an Intersight object moid by name
206
207        :param resource_path: intersight resource path e.g. '/ntp/Policies'
208        :param target_name: intersight object name
209        :return: json http response object
210        """
211        query_params = {
212            "$filter": "Name eq '{0}'".format(target_name)
213        }
214
215        options = {
216            "http_method": "GET",
217            "resource_path": resource_path,
218            "query_params": query_params
219        }
220
221        get_moid = self.intersight_call(**options)
222
223        if get_moid.json()['Results'] is not None:
224            located_moid = get_moid.json()['Results'][0]['Moid']
225        else:
226            raise KeyError('Intersight object with name "{0}" not found!'.format(target_name))
227
228        return located_moid
229
230    def call_api(self, **options):
231        """
232        Call the Intersight API and check for success status
233        :param options: options dict with method and other params for API call
234        :return: json http response object
235        """
236
237        try:
238            response, info = self.intersight_call(**options)
239            if not re.match(r'2..', str(info['status'])):
240                raise RuntimeError(info['status'], info['msg'], info['body'])
241        except Exception as e:
242            self.module.fail_json(msg="API error: %s " % str(e))
243
244        response_data = response.read()
245        if len(response_data) > 0:
246            resp_json = json.loads(response_data)
247            resp_json['trace_id'] = info.get('x-starship-traceid')
248            return resp_json
249        return {}
250
251    def intersight_call(self, http_method="", resource_path="", query_params=None, body=None, moid=None, name=None):
252        """
253        Invoke the Intersight API
254
255        :param resource_path: intersight resource path e.g. '/ntp/Policies'
256        :param query_params: dictionary object with query string parameters as key/value pairs
257        :param body: dictionary object with intersight data
258        :param moid: intersight object moid
259        :param name: intersight object name
260        :return: json http response object
261        """
262
263        target_host = urlparse(self.host).netloc
264        target_path = urlparse(self.host).path
265        query_path = ""
266        method = http_method.upper()
267        bodyString = ""
268
269        # Verify an accepted HTTP verb was chosen
270        if(method not in ['GET', 'POST', 'PATCH', 'DELETE']):
271            raise ValueError('Please select a valid HTTP verb (GET/POST/PATCH/DELETE)')
272
273        # Verify the resource path isn't empy & is a valid <str> object
274        if(resource_path != "" and not (resource_path, str)):
275            raise TypeError('The *resource_path* value is required and must be of type "<str>"')
276
277        # Verify the query parameters isn't empy & is a valid <dict> object
278        if(query_params is not None and not isinstance(query_params, dict)):
279            raise TypeError('The *query_params* value must be of type "<dict>"')
280
281        # Verify the MOID is not null & of proper length
282        if(moid is not None and len(moid.encode('utf-8')) != 24):
283            raise ValueError('Invalid *moid* value!')
284
285        # Check for query_params, encode, and concatenate onto URL
286        if query_params:
287            query_path = "?" + urlencode(query_params)
288
289        # Handle PATCH/DELETE by Object "name" instead of "moid"
290        if method in ('PATCH', 'DELETE'):
291            if moid is None:
292                if name is not None:
293                    if isinstance(name, str):
294                        moid = self.get_moid_by_name(resource_path, name)
295                    else:
296                        raise TypeError('The *name* value must be of type "<str>"')
297                else:
298                    raise ValueError('Must set either *moid* or *name* with "PATCH/DELETE!"')
299
300        # Check for moid and concatenate onto URL
301        if moid is not None:
302            resource_path += "/" + moid
303
304        # Check for GET request to properly form body
305        if method != "GET":
306            bodyString = json.dumps(body)
307
308        # Concatenate URLs for headers
309        target_url = self.host + resource_path + query_path
310        request_target = method.lower() + " " + target_path + resource_path + query_path
311
312        # Get the current GMT Date/Time
313        cdate = get_gmt_date()
314
315        # Generate the body digest
316        body_digest = get_sha256_digest(bodyString)
317        b64_body_digest = b64encode(body_digest.digest())
318
319        # Generate the authorization header
320        auth_header = {
321            'Host': target_host,
322            'Date': cdate,
323            'Digest': "SHA-256=" + b64_body_digest.decode('ascii'),
324        }
325
326        string_to_sign = prepare_str_to_sign(request_target, auth_header)
327        b64_signed_msg = self.get_sig_b64encode(string_to_sign)
328        auth_header = self.get_auth_header(auth_header, b64_signed_msg)
329
330        # Generate the HTTP requests header
331        request_header = {
332            'Accept': 'application/json',
333            'Content-Type': 'application/json',
334            'Host': '{0}'.format(target_host),
335            'Date': '{0}'.format(cdate),
336            'Digest': 'SHA-256={0}'.format(b64_body_digest.decode('ascii')),
337            'Authorization': '{0}'.format(auth_header),
338        }
339
340        response, info = fetch_url(self.module, target_url, data=bodyString, headers=request_header, method=method, use_proxy=self.module.params['use_proxy'])
341
342        return response, info
343
344    def get_resource(self, resource_path, query_params, return_list=False):
345        '''
346        GET a resource and return the 1st element found or the full Results list
347        '''
348        options = {
349            'http_method': 'get',
350            'resource_path': resource_path,
351            'query_params': query_params,
352        }
353        response = self.call_api(**options)
354        if response.get('Results'):
355            if return_list:
356                self.result['api_response'] = response['Results']
357            else:
358                # return the 1st list element
359                self.result['api_response'] = response['Results'][0]
360        self.result['trace_id'] = response.get('trace_id')
361
362    def configure_resource(self, moid, resource_path, body, query_params, update_method=''):
363        if not self.module.check_mode:
364            if moid and update_method != 'post':
365                # update the resource - user has to specify all the props they want updated
366                options = {
367                    'http_method': 'patch',
368                    'resource_path': resource_path,
369                    'body': body,
370                    'moid': moid,
371                }
372                response_dict = self.call_api(**options)
373                if response_dict.get('Results'):
374                    # return the 1st element in the results list
375                    self.result['api_response'] = response_dict['Results'][0]
376                    self.result['trace_id'] = response_dict.get('trace_id')
377            else:
378                # create the resource
379                options = {
380                    'http_method': 'post',
381                    'resource_path': resource_path,
382                    'body': body,
383                }
384                response_dict = self.call_api(**options)
385                if response_dict:
386                    self.result['api_response'] = response_dict
387                    self.result['trace_id'] = response_dict.get('trace_id')
388                elif query_params:
389                    # POSTs may not return any data.
390                    # Get the current state of the resource if query_params.
391                    self.get_resource(
392                        resource_path=resource_path,
393                        query_params=query_params,
394                    )
395        self.result['changed'] = True
396
397    def delete_resource(self, moid, resource_path):
398        # delete resource and create empty api_response
399        if not self.module.check_mode:
400            options = {
401                'http_method': 'delete',
402                'resource_path': resource_path,
403                'moid': moid,
404            }
405            resp = self.call_api(**options)
406            self.result['api_response'] = {}
407            self.result['trace_id'] = resp.get('trace_id')
408        self.result['changed'] = True
409
410    def configure_policy_or_profile(self, resource_path):
411        # Configure (create, update, or delete) the policy or profile
412        organization_moid = None
413        # GET Organization Moid
414        self.get_resource(
415            resource_path='/organization/Organizations',
416            query_params={
417                '$filter': "Name eq '" + self.module.params['organization'] + "'",
418                '$select': 'Moid',
419            },
420        )
421        if self.result['api_response'].get('Moid'):
422            # resource exists and moid was returned
423            organization_moid = self.result['api_response']['Moid']
424
425        self.result['api_response'] = {}
426        # Get the current state of the resource
427        filter_str = "Name eq '" + self.module.params['name'] + "'"
428        filter_str += "and Organization.Moid eq '" + organization_moid + "'"
429        self.get_resource(
430            resource_path=resource_path,
431            query_params={
432                '$filter': filter_str,
433                '$expand': 'Organization',
434            }
435        )
436
437        moid = None
438        resource_values_match = False
439        if self.result['api_response'].get('Moid'):
440            # resource exists and moid was returned
441            moid = self.result['api_response']['Moid']
442            if self.module.params['state'] == 'present':
443                resource_values_match = compare_values(self.api_body, self.result['api_response'])
444            else:  # state == 'absent'
445                self.delete_resource(
446                    moid=moid,
447                    resource_path=resource_path,
448                )
449                moid = None
450
451        if self.module.params['state'] == 'present' and not resource_values_match:
452            # remove read-only Organization key
453            self.api_body.pop('Organization')
454            if not moid:
455                # Organization must be set, but can't be changed after initial POST
456                self.api_body['Organization'] = {
457                    'Moid': organization_moid,
458                }
459            self.configure_resource(
460                moid=moid,
461                resource_path=resource_path,
462                body=self.api_body,
463                query_params={
464                    '$filter': filter_str
465                }
466            )
467            if self.result['api_response'].get('Moid'):
468                # resource exists and moid was returned
469                moid = self.result['api_response']['Moid']
470
471        return moid
472