1"""
2The Client class, handling direct communication with the API
3"""
4
5from __future__ import print_function
6
7import base64
8import os
9import time
10import uuid
11from collections import OrderedDict
12
13from cryptography.hazmat.backends import default_backend
14from cryptography.hazmat.primitives import serialization
15from cryptography.hazmat.primitives import hashes
16from cryptography.hazmat.primitives.asymmetric import padding
17from suds.client import Client as SudsClient
18from suds.sudsobject import Object as SudsObject
19from suds.xsd.doctor import Import, ImportDoctor
20from suds.plugin import DocumentPlugin
21
22from . import __version__
23
24try:
25    from urllib.parse import urlencode, quote_plus
26except ImportError:
27    from urllib import urlencode, quote_plus
28
29try:
30    import suds_requests
31except ImportError:
32    suds_requests = None
33
34
35URI_TEMPLATE = 'https://{}/wsdl/?service={}'
36
37MODE_RO = 'readonly'
38MODE_RW = 'readwrite'
39
40
41def convert_value(value):
42    """
43    None and boolean values are not accepted by the Transip API.
44    This method converts
45        - None and False to an empty string,
46        - True to 1
47    """
48    if isinstance(value, bool):
49        return 1 if value else ''
50
51    if not value:
52        return ''
53
54    return value
55
56
57class WSDLFixPlugin(DocumentPlugin):
58    # pylint: disable=W0232
59    """
60    A SudsFilter to fix wsdl document before it is parsed.
61    """
62
63    def loaded(self, context):
64        # pylint: disable=R0201
65        """
66        Replaces an invalid type in the wsdl document with a validy type.
67        """
68        context.document = context.document.replace(b'xsd:array', b'soapenc:Array')
69
70
71class Client(object):
72    # pylint: disable=R0205
73    """
74    A client-base class, for other classes to base their service implementation
75    on. Contains methods to set and sign cookie and to retrieve the correct
76    WSDL for specific parts of the TransIP API.
77
78    Note:
79        You either need to supply a private_key or a private_key_file.
80
81    Args:
82        service_name (str): Name of the service.
83        login (str): The TransIP username.
84        private_key (str, optional): The content of the private key for
85            accessing the TransIP API.
86        private_key_file (str, optional): Path the the private key for
87            accesing the TransIP API. Defaults to 'decrypted_key'.
88        endpoint (str): The TransIP API endpoint. Defaults to 'api.transip.nl'.
89    """
90    def __init__(self, service_name, login, private_key=None,
91                 private_key_file='decrypted_key', endpoint='api.transip.nl'):
92        self.service_name = service_name
93        self.login = login
94        self.private_key = private_key
95        self.private_key_file = private_key_file
96        self.endpoint = endpoint
97        self.url = URI_TEMPLATE.format(endpoint, service_name)
98
99        imp = Import('http://schemas.xmlsoap.org/soap/encoding/')
100        doc = ImportDoctor(imp)
101
102        suds_kwargs = dict()
103        if suds_requests:
104            suds_kwargs['transport'] = suds_requests.RequestsTransport()
105
106        self.soap_client = SudsClient(self.url, doctor=doc, plugins=[WSDLFixPlugin()], **suds_kwargs)
107
108    def _sign(self, message):
109        """ Uses the decrypted private key to sign the message. """
110        if self.private_key:
111            keydata = self.private_key
112        elif os.path.exists(self.private_key_file):
113            with open(self.private_key_file) as private_key:
114                keydata = private_key.read()
115        else:
116            raise RuntimeError('The private key does not exist.')
117
118        private_key = serialization.load_pem_private_key(
119            str.encode(keydata),
120            password=None,
121            backend=default_backend()
122        )
123        signature = private_key.sign(
124            str.encode(message),
125            padding.PKCS1v15(),
126            hashes.SHA512(),
127        )
128
129        signature = base64.b64encode(signature)
130        signature = quote_plus(signature)
131
132        return signature
133
134    def _build_signature_message(self, service_name, method_name,
135                                 timestamp, nonce, additional=None):
136        """
137        Builds the message that should be signed. This message contains
138        specific information about the request in a specific order.
139        """
140        if additional is None:
141            additional = []
142
143        sign = OrderedDict()
144        # Add all additional parameters first
145        for index, value in enumerate(additional):
146            if isinstance(value, list):
147                for entryindex, entryvalue in enumerate(value):
148                    if isinstance(entryvalue, SudsObject):
149                        for objectkey, objectvalue in entryvalue:
150                            objectvalue = convert_value(objectvalue)
151                            sign[str(index) + '[' + str(entryindex) + '][' + objectkey + ']'] = objectvalue
152            elif isinstance(value, SudsObject):
153                for entryindex, entryvalue in value:
154                    key = str(index) + '[' + str(entryindex) + ']'
155                    sign[key] = convert_value(entryvalue)
156            else:
157                sign[index] = convert_value(value)
158        sign['__method'] = method_name
159        sign['__service'] = service_name
160        sign['__hostname'] = self.endpoint
161        sign['__timestamp'] = timestamp
162        sign['__nonce'] = nonce
163
164        return urlencode(sign) \
165            .replace('%5B', '[') \
166            .replace('%5D', ']') \
167            .replace('+', '%20') \
168            .replace('%7E', '~')  # Comply with RFC3989. This replacement is also in TransIP's sample PHP library.
169
170    def update_cookie(self, cookies):
171        """ Updates the cookie for the upcoming call to the API. """
172        temp = []
173        for k, val in cookies.items():
174            temp.append("%s=%s" % (k, val))
175
176        cookiestring = ';'.join(temp)
177        self.soap_client.set_options(headers={'Cookie': cookiestring})
178
179    def build_cookie(self, method, mode, parameters=None):
180        """
181        Build a cookie for the request.
182
183        Keyword arguments:
184        method -- the method to be called on the service.
185        mode -- Read-only (MODE_RO) or read-write (MODE_RW)
186        """
187        timestamp = int(time.time())
188        nonce = str(uuid.uuid4())[:32]
189
190        message_to_sign = self._build_signature_message(
191            service_name=self.service_name,
192            method_name=method,
193            timestamp=timestamp,
194            nonce=nonce,
195            additional=parameters
196        )
197
198        signature = self._sign(message_to_sign)
199
200        cookies = {
201            "nonce": nonce,
202            "timestamp": timestamp,
203            "mode": mode,
204            "clientVersion": __version__,
205            "login": self.login,
206            "signature": signature
207        }
208
209        return cookies
210
211    def _simple_request(self, method, *args, **kwargs):
212        """
213        Helper method to create a request in a DRY way
214        """
215        cookie = self.build_cookie(mode=kwargs.get('mode', MODE_RO), method=method, parameters=args)
216        self.update_cookie(cookie)
217
218        return getattr(self.soap_client.service, method)(*args)
219