1# Licensed to the Apache Software Foundation (ASF) under one or more
2# contributor license agreements.  See the NOTICE file distributed with
3# this work for additional information regarding copyright ownership.
4# The ASF licenses this file to You under the Apache License, Version 2.0
5# (the "License"); you may not use this file except in compliance with
6# the License.  You may obtain a copy of the License at
7#
8#     http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""
17Subclass for httplib.HTTPSConnection with optional certificate name
18verification, depending on libcloud.security settings.
19"""
20
21import os
22import warnings
23import requests
24from requests.adapters import HTTPAdapter
25from requests.packages.urllib3.poolmanager import PoolManager
26
27import libcloud.security
28from libcloud.utils.py3 import urlparse, PY3
29
30
31__all__ = [
32    'LibcloudBaseConnection',
33    'LibcloudConnection'
34]
35
36ALLOW_REDIRECTS = 1
37
38# Default timeout for HTTP requests in seconds
39DEFAULT_REQUEST_TIMEOUT = 60
40
41HTTP_PROXY_ENV_VARIABLE_NAME = 'http_proxy'
42HTTPS_PROXY_ENV_VARIABLE_NAME = 'https_proxy'
43
44
45class SignedHTTPSAdapter(HTTPAdapter):
46    def __init__(self, cert_file, key_file):
47        self.cert_file = cert_file
48        self.key_file = key_file
49        super(SignedHTTPSAdapter, self).__init__()
50
51    def init_poolmanager(self, connections, maxsize, block=False):
52        self.poolmanager = PoolManager(
53            num_pools=connections, maxsize=maxsize,
54            block=block,
55            cert_file=self.cert_file,
56            key_file=self.key_file)
57
58
59class LibcloudBaseConnection(object):
60    """
61    Base connection class to inherit from.
62
63    Note: This class should not be instantiated directly.
64    """
65
66    session = None
67
68    proxy_scheme = None
69    proxy_host = None
70    proxy_port = None
71
72    proxy_username = None
73    proxy_password = None
74
75    http_proxy_used = False
76
77    ca_cert = None
78
79    def __init__(self):
80        self.session = requests.Session()
81
82    def set_http_proxy(self, proxy_url):
83        """
84        Set a HTTP proxy which will be used with this connection.
85
86        :param proxy_url: Proxy URL (e.g. http://<hostname>:<port> without
87                          authentication and
88                          http://<username>:<password>@<hostname>:<port> for
89                          basic auth authentication information.
90        :type proxy_url: ``str``
91        """
92        result = self._parse_proxy_url(proxy_url=proxy_url)
93
94        scheme = result[0]
95        host = result[1]
96        port = result[2]
97        username = result[3]
98        password = result[4]
99
100        self.proxy_scheme = scheme
101        self.proxy_host = host
102        self.proxy_port = port
103        self.proxy_username = username
104        self.proxy_password = password
105        self.http_proxy_used = True
106
107        self.session.proxies = {
108            'http': proxy_url,
109            'https': proxy_url,
110        }
111
112    def _parse_proxy_url(self, proxy_url):
113        """
114        Parse and validate a proxy URL.
115
116        :param proxy_url: Proxy URL (e.g. http://hostname:3128)
117        :type proxy_url: ``str``
118
119        :rtype: ``tuple`` (``scheme``, ``hostname``, ``port``)
120        """
121        parsed = urlparse.urlparse(proxy_url)
122
123        if parsed.scheme not in ('http', 'https'):
124            raise ValueError('Only http and https proxies are supported')
125
126        if not parsed.hostname or not parsed.port:
127            raise ValueError('proxy_url must be in the following format: '
128                             '<scheme>://<proxy host>:<proxy port>')
129
130        proxy_scheme = parsed.scheme
131        proxy_host, proxy_port = parsed.hostname, parsed.port
132
133        netloc = parsed.netloc
134
135        if '@' in netloc:
136            username_password = netloc.split('@', 1)[0]
137            split = username_password.split(':', 1)
138
139            if len(split) < 2:
140                raise ValueError('URL is in an invalid format')
141
142            proxy_username, proxy_password = split[0], split[1]
143        else:
144            proxy_username = None
145            proxy_password = None
146
147        return (proxy_scheme, proxy_host, proxy_port, proxy_username,
148                proxy_password)
149
150    def _setup_verify(self):
151        self.verify = libcloud.security.VERIFY_SSL_CERT
152
153    def _setup_ca_cert(self, **kwargs):
154        # simulating keyword-only argument in Python 2
155        ca_certs_path = kwargs.get('ca_cert', libcloud.security.CA_CERTS_PATH)
156
157        if self.verify is False:
158            pass
159        else:
160            if isinstance(ca_certs_path, list):
161                msg = (
162                    'Providing a list of CA trusts is no longer supported '
163                    'since libcloud 2.0. Using the first element in the list. '
164                    'See http://libcloud.readthedocs.io/en/latest/other/'
165                    'changes_in_2_0.html#providing-a-list-of-ca-trusts-is-no-'
166                    'longer-supported')
167                warnings.warn(msg, DeprecationWarning)
168                self.ca_cert = ca_certs_path[0]
169            else:
170                self.ca_cert = ca_certs_path
171
172    def _setup_signing(self, cert_file=None, key_file=None):
173        """
174        Setup request signing by mounting a signing
175        adapter to the session
176        """
177        self.session.mount('https://', SignedHTTPSAdapter(cert_file, key_file))
178
179
180class LibcloudConnection(LibcloudBaseConnection):
181    timeout = None
182    host = None
183    response = None
184
185    def __init__(self, host, port, secure=None, **kwargs):
186        scheme = 'https' if secure is not None and secure else 'http'
187        self.host = '{0}://{1}{2}'.format(
188            'https' if port == 443 else scheme,
189            host,
190            ":{0}".format(port) if port not in (80, 443) else ""
191        )
192
193        # Support for HTTP(s) proxy
194        # NOTE: We always only use a single proxy (either HTTP or HTTPS)
195        https_proxy_url_env = os.environ.get(HTTPS_PROXY_ENV_VARIABLE_NAME,
196                                             None)
197        http_proxy_url_env = os.environ.get(HTTP_PROXY_ENV_VARIABLE_NAME,
198                                            https_proxy_url_env)
199
200        # Connection argument has precedence over environment variables
201        proxy_url = kwargs.pop('proxy_url', http_proxy_url_env)
202
203        self._setup_verify()
204        self._setup_ca_cert()
205
206        LibcloudBaseConnection.__init__(self)
207
208        self.session.timeout = kwargs.pop('timeout', DEFAULT_REQUEST_TIMEOUT)
209
210        if 'cert_file' in kwargs or 'key_file' in kwargs:
211            self._setup_signing(**kwargs)
212
213        if proxy_url:
214            self.set_http_proxy(proxy_url=proxy_url)
215
216    @property
217    def verification(self):
218        """
219        The option for SSL verification given to underlying requests
220        """
221        return self.ca_cert if self.ca_cert is not None else self.verify
222
223    def request(self, method, url, body=None, headers=None, raw=False,
224                stream=False, hooks=None):
225        url = urlparse.urljoin(self.host, url)
226        headers = self._normalize_headers(headers=headers)
227
228        self.response = self.session.request(
229            method=method.lower(),
230            url=url,
231            data=body,
232            headers=headers,
233            allow_redirects=ALLOW_REDIRECTS,
234            stream=stream,
235            verify=self.verification,
236            timeout=self.session.timeout,
237            hooks=hooks,
238        )
239
240    def prepared_request(self, method, url, body=None,
241                         headers=None, raw=False, stream=False):
242        headers = self._normalize_headers(headers=headers)
243
244        req = requests.Request(method, ''.join([self.host, url]),
245                               data=body, headers=headers)
246
247        prepped = self.session.prepare_request(req)
248
249        self.response = self.session.send(
250            prepped,
251            stream=stream,
252            verify=self.ca_cert if self.ca_cert is not None else self.verify)
253
254    def getresponse(self):
255        return self.response
256
257    def getheaders(self):
258        # urlib decoded response body, libcloud has a bug
259        # and will not check if content is gzipped, so let's
260        # remove headers indicating compressed content.
261        if 'content-encoding' in self.response.headers:
262            del self.response.headers['content-encoding']
263        return self.response.headers
264
265    @property
266    def status(self):
267        return self.response.status_code
268
269    @property
270    def reason(self):
271        return None if self.response.status_code > 400 else self.response.text
272
273    def connect(self):  # pragma: no cover
274        pass
275
276    def read(self):
277        return self.response.content
278
279    def close(self):  # pragma: no cover
280        # return connection back to pool
281        self.response.close()
282
283    def _normalize_headers(self, headers):
284        headers = headers or {}
285
286        # all headers should be strings
287        for key, value in headers.items():
288            if isinstance(value, (int, float)):
289                headers[key] = str(value)
290
291        return headers
292
293
294class HttpLibResponseProxy(object):
295    """
296    Provides a proxy pattern around the :class:`requests.Reponse`
297    object to a :class:`httplib.HTTPResponse` object
298    """
299    def __init__(self, response):
300        self._response = response
301
302    def read(self, amt=None):
303        return self._response.text
304
305    def getheader(self, name, default=None):
306        """
307        Get the contents of the header name, or default
308        if there is no matching header.
309        """
310        if name in self._response.headers.keys():
311            return self._response.headers[name]
312        else:
313            return default
314
315    def getheaders(self):
316        """
317        Return a list of (header, value) tuples.
318        """
319        if PY3:
320            return list(self._response.headers.items())
321        else:
322            return self._response.headers.items()
323
324    @property
325    def status(self):
326        return self._response.status_code
327
328    @property
329    def reason(self):
330        return self._response.reason
331
332    @property
333    def version(self):
334        # requests doesn't expose this
335        return '11'
336
337    @property
338    def body(self):
339        # NOTE: We use property to avoid saving whole response body into RAM
340        # See https://github.com/apache/libcloud/pull/1132 for details
341        return self._response.content
342