diff --git a/files/hotfix/python-fedora/proxyclient.py b/files/hotfix/python-fedora/proxyclient.py new file mode 100644 index 0000000000..1c36d67903 --- /dev/null +++ b/files/hotfix/python-fedora/proxyclient.py @@ -0,0 +1,496 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2013 Red Hat, Inc. +# This file is part of python-fedora +# +# python-fedora is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# python-fedora is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with python-fedora; if not, see +# +'''Implement a class that sets up simple communication to a Fedora Service. + +.. moduleauthor:: Luke Macken +.. moduleauthor:: Toshio Kuratomi +''' + +import Cookie +import copy +import urllib +import httplib +import logging +# For handling an exception that's coming from requests: +import ssl +import time +import warnings + +try: + from urlparse import urljoin + from urlparse import urlparse +except ImportError: + # Python3 support + from urllib.parse import urljoin + from urllib.parse import urlparse + +try: + from hashlib import sha1 as sha_constructor +except ImportError: + from sha import new as sha_constructor + +from bunch import bunchify +from kitchen.text.converters import to_bytes +import requests +# For handling an exception that's coming from requests: +import urllib3 + +from fedora import __version__, b_ +from fedora.client import AppError, AuthError, ServerError + +log = logging.getLogger(__name__) + + +class ProxyClient(object): + # pylint: disable-msg=R0903 + ''' + A client to a Fedora Service. This class is optimized to proxy multiple + users to a service. ProxyClient is designed to be threadsafe so that + code can instantiate one instance of the class and use it for multiple + requests for different users from different threads. + + If you want something that can manage one user's connection to a Fedora + Service, then look into using BaseClient instead. + + This class has several attributes. These may be changed after + instantiation however, please note that this class is intended to be + threadsafe. Changing these values when another thread may affect more + than just the thread that you are making the change in. (For instance, + changing the debug option could cause other threads to start logging debug + messages in the middle of a method.) + + .. attribute:: base_url + + Initial portion of the url to contact the server. It is highly + recommended not to change this value unless you know that no other + threads are accessing this :class:`ProxyClient` instance. + + .. attribute:: useragent + + Changes the useragent string that is reported to the web server. + + .. attribute:: session_name + + Name of the cookie that holds the authentication value. + + .. attribute:: session_as_cookie + + If :data:`True`, then the session information is saved locally as + a cookie. This is here for backwards compatibility. New code should + set this to :data:`False` when constructing the :class:`ProxyClient`. + + .. attribute:: debug + + If :data:`True`, then more verbose logging is performed to aid in + debugging issues. + + .. attribute:: insecure + + If :data:`True` then the connection to the server is not checked to be + sure that any SSL certificate information is valid. That means that + a remote host can lie about who it is. Useful for development but + should not be used in production code. + + .. attribute:: retries + + Setting this to a positive integer will retry failed requests to the + web server this many times. Setting to a negative integer will retry + forever. + + .. attribute:: timeout + A float describing the timeout of the connection. The timeout only + affects the connection process itself, not the downloading of the + response body. Defaults to 120 seconds. + + .. versionchanged:: 0.3.33 + Added the timeout attribute + ''' + log = log + + def __init__(self, base_url, useragent=None, session_name='tg-visit', + session_as_cookie=True, debug=False, insecure=False, retries=None, + timeout=None): + '''Create a client configured for a particular service. + + :arg base_url: Base of every URL used to contact the server + + :kwarg useragent: useragent string to use. If not given, default to + "Fedora ProxyClient/VERSION" + :kwarg session_name: name of the cookie to use with session handling + :kwarg session_as_cookie: If set to True, return the session as a + SimpleCookie. If False, return a session_id. This flag allows us + to maintain compatibility for the 0.3 branch. In 0.4, code will + have to deal with session_id's instead of cookies. + :kwarg debug: If True, log debug information + :kwarg insecure: If True, do not check server certificates against + their CA's. This means that man-in-the-middle attacks are + possible against the `BaseClient`. You might turn this option on + for testing against a local version of a server with a self-signed + certificate but it should be off in production. + :kwarg retries: if we get an unknown or possibly transient error from + the server, retry this many times. Setting this to a negative + number makes it try forever. Defaults to zero, no retries. + :kwarg timeout: A float describing the timeout of the connection. The + timeout only affects the connection process itself, not the downloading + of the response body. Defaults to 120 seconds. + + .. versionchanged:: 0.3.33 + Added the timeout kwarg + ''' + # Setup our logger + self._log_handler = logging.StreamHandler() + self.debug = debug + format = logging.Formatter("%(message)s") + self._log_handler.setFormatter(format) + self.log.addHandler(self._log_handler) + + # When we are instantiated, go ahead and silence the python-requests + # log. It is kind of noisy in our app server logs. + if not debug: + requests_log = logging.getLogger("requests") + requests_log.setLevel(logging.WARN) + + self.log.debug(b_('proxyclient.__init__:entered')) + if base_url[-1] != '/': + base_url = base_url +'/' + self.base_url = base_url + self.domain = urlparse(self.base_url).netloc + self.useragent = useragent or 'Fedora ProxyClient/%(version)s' % { + 'version': __version__} + self.session_name = session_name + self.session_as_cookie = session_as_cookie + if session_as_cookie: + warnings.warn(b_('Returning cookies from send_request() is' + ' deprecated and will be removed in 0.4. Please port your' + ' code to use a session_id instead by calling the ProxyClient' + ' constructor with session_as_cookie=False'), + DeprecationWarning, stacklevel=2) + self.insecure = insecure + + # Have to do retries and timeout default values this way as BaseClient + # sends None for these values if not overridden by the user. + if retries is None: + self.retries = 0 + else: + self.retries = retries + if timeout is None: + self.timeout = 120.0 + else: + self.timeout = timeout + self.log.debug(b_('proxyclient.__init__:exited')) + + def __get_debug(self): + '''Return whether we have debug logging turned on. + + :Returns: True if debugging is on, False otherwise. + ''' + if self._log_handler.level <= logging.DEBUG: + return True + return False + + def __set_debug(self, debug=False): + '''Change debug level. + + :kwarg debug: A true value to turn debugging on, false value to turn it + off. + ''' + if debug: + self.log.setLevel(logging.DEBUG) + self._log_handler.setLevel(logging.DEBUG) + else: + self.log.setLevel(logging.ERROR) + self._log_handler.setLevel(logging.INFO) + + debug = property(__get_debug, __set_debug, doc=''' + When True, we log extra debugging statements. When False, we only log + errors. + ''') + + def send_request(self, method, req_params=None, auth_params=None, + file_params=None, retries=None, timeout=None): + '''Make an HTTP request to a server method. + + The given method is called with any parameters set in ``req_params``. + If auth is True, then the request is made with an authenticated session + cookie. Note that path parameters should be set by adding onto the + method, not via ``req_params``. + + :arg method: Method to call on the server. It's a url fragment that + comes after the base_url set in __init__(). Note that any + parameters set as extra path information should be listed here, + not in ``req_params``. + :kwarg req_params: dict containing extra parameters to send to the + server + :kwarg auth_params: dict containing one or more means of authenticating + to the server. Valid entries in this dict are: + + :cookie: **Deprecated** Use ``session_id`` instead. If both + ``cookie`` and ``session_id`` are set, only ``session_id`` will + be used. A ``Cookie.SimpleCookie`` to send as a session cookie + to the server + :session_id: Session id to put in a cookie to construct an identity + for the server + :username: Username to send to the server + :password: Password to use with username to send to the server + :httpauth: If set to ``basic`` then use HTTP Basic Authentication + to send the username and password to the server. This may be + extended in the future to support other httpauth types than + ``basic``. + + Note that cookie can be sent alone but if one of username or + password is set the other must as well. Code can set all of these + if it wants and all of them will be sent to the server. Be careful + of sending cookies that do not match with the username in this + case as the server can decide what to do in this case. + :kwarg file_params: dict of files where the key is the name of the + file field used in the remote method and the value is the local + path of the file to be uploaded. If you want to pass multiple + files to a single file field, pass the paths as a list of paths. + :kwarg retries: if we get an unknown or possibly transient error from + the server, retry this many times. Setting this to a negative + number makes it try forever. Default to use the :attr:`retries` + value set on the instance or in :meth:`__init__`. + :kwarg timeout: A float describing the timeout of the connection. The + timeout only affects the connection process itself, not the + downloading of the response body. Defaults to the :attr:`timeout` + value set on the instance or in :meth:`__init__`. + :returns: If ProxyClient is created with session_as_cookie=True (the + default), a tuple of session cookie and data from the server. + If ProxyClient was created with session_as_cookie=False, a tuple + of session_id and data instead. + :rtype: tuple of session information and data from server + + .. versionchanged:: 0.3.17 + No longer send tg_format=json parameter. We rely solely on the + Accept: application/json header now. + .. versionchanged:: 0.3.21 + * Return data as a Bunch instead of a DictContainer + * Add file_params to allow uploading files + .. versionchanged:: 0.3.33 + Added the timeout kwarg + ''' + self.log.debug(b_('proxyclient.send_request: entered')) + + # parameter mangling + file_params = file_params or {} + + # Check whether we need to authenticate for this request + session_id = None + username = None + password = None + if auth_params: + if 'session_id' in auth_params: + session_id = auth_params['session_id'] + elif 'cookie' in auth_params: + warnings.warn(b_('Giving a cookie to send_request() to' + ' authenticate is deprecated and will be removed in 0.4.' + ' Please port your code to use session_id instead.'), + DeprecationWarning, stacklevel=2) + session_id = auth_params['cookie'].output(attrs=[], + header='').strip() + if 'username' in auth_params and 'password' in auth_params: + username = auth_params['username'] + password = auth_params['password'] + elif 'username' in auth_params or 'password' in auth_params: + raise AuthError(b_('username and password must both be set in' + ' auth_params')) + if not (session_id or username): + raise AuthError(b_('No known authentication methods' + ' specified: set "cookie" in auth_params or set both' + ' username and password in auth_params')) + + # urljoin is slightly different than os.path.join(). Make sure method + # will work with it. + method = method.lstrip('/') + # And join to make our url. + url = urljoin(self.base_url, urllib.quote(method)) + + data = None # decoded JSON via json.load() + + # Set standard headers + headers = { + 'User-agent': self.useragent, + 'Accept': 'application/json', + } + + # Files to upload + for field_name, local_file_name in file_params: + file_params[field_name] = open(local_file_name, 'rb') + + cookies = requests.cookies.RequestsCookieJar() + # If we have a session_id, send it + if session_id: + # Anytime the session_id exists, send it so that visit tracking + # works. Will also authenticate us if there's a need. Note that + # there's no need to set other cookie attributes because this is a + # cookie generated client-side. + cookies.set(self.session_name, session_id) + + complete_params = req_params or {} + if session_id: + # Add the csrf protection token + token = sha_constructor(session_id) + complete_params.update({'_csrf_token': token.hexdigest()}) + + auth = None + if username and password: + if auth_params.get('httpauth', '').lower() == 'basic': + # HTTP Basic auth login + auth = (username, password) + else: + # TG login + # Adding this to the request data prevents it from being logged by + # apache. + complete_params.update({ + 'user_name': to_bytes(username), + 'password': to_bytes(password), + 'login': 'Login', + }) + + # If debug, give people our debug info + self.log.debug(b_('Creating request %(url)s') % + {'url': to_bytes(url)}) + self.log.debug(b_('Headers: %(header)s') % + {'header': to_bytes(headers, nonstring='simplerepr')}) + if self.debug and complete_params: + debug_data = copy.deepcopy(complete_params) + + if 'password' in debug_data: + debug_data['password'] = 'xxxxxxx' + + self.log.debug(b_('Data: %r') % debug_data) + + if retries is None: + retries = self.retries + + if timeout is None: + timeout = self.timeout + + num_tries = 0 + while True: + try: + response = requests.post( + url, + data=complete_params, + cookies=cookies, + headers=headers, + auth=auth, + verify=not self.insecure, + timeout=timeout, + ) + except (requests.Timeout, requests.exceptions.SSLError) as e: + if isinstance(e, requests.exceptions.SSLError): + # And now we know how not to code a library exception + # hierarchy... We're expecting that requests is raising + # the following stupidity: + # requests.exceptions.SSLError( + # urllib3.exceptions.SSLError( + # ssl.SSLError('The read operation timed out'))) + # If we weren't interested in reraising the exception with + # full tracdeback we could use a try: except instead of + # this gross conditional. But we need to code defensively + # because we don't want to raise an unrelated exception + # here and if requests/urllib3 can do this sort of + # nonsense, they may change the nonsense in the future + if not (e.args and isinstance(e.args[0], + urllib3.exceptions.SSLError) + and e.args[0].args + and isinstance(e.args[0].args[0], ssl.SSLError) + and e.args[0].args[0].args + and 'timed out' in e.args[0].args[0].args[0]): + # We're only interested in timeouts here + raise + self.log.debug(b_('Request timed out')) + if retries < 0 or num_tries < retries: + num_tries += 1 + self.log.debug(b_('Attempt #%(try)s failed') % {'try': num_tries}) + time.sleep(0.5) + continue + # Fail and raise an error + # Raising our own exception protects the user from the + # implementation detail of requests vs pycurl vs urllib + raise ServerError(url, -1, 'Request timed out after %s seconds' % timeout) + + # When the python-requests module gets a response, it attempts to + # guess the encoding using chardet (or a fork) + # That process can take an extraordinarily long time for long + # response.text strings.. upwards of 30 minutes for FAS queries to + # /accounts/user/list JSON api! Therefore, we cut that codepath + # off at the pass by assuming that the response is 'utf-8'. We can + # make that assumption because we're only interfacing with servers + # that we run (and we know that they all return responses + # encoded 'utf-8'). + response.encoding = 'utf-8' + + # Check for auth failures + # Note: old TG apps returned 403 Forbidden on authentication failures. + # Updated apps return 401 Unauthorized + # We need to accept both until all apps are updated to return 401. + http_status = response.status_code + if http_status in (401, 403): + # Wrong username or password + self.log.debug(b_('Authentication failed logging in')) + raise AuthError(b_('Unable to log into server. Invalid' + ' authentication tokens. Send new username and password')) + elif http_status >= 400: + if retries < 0 or num_tries < retries: + # Retry the request + num_tries += 1 + self.log.debug(b_('Attempt #%(try)s failed') % {'try': num_tries}) + time.sleep(0.5) + continue + # Fail and raise an error + try: + msg = httplib.responses[http_status] + except (KeyError, AttributeError): + msg = b_('Unknown HTTP Server Response') + raise ServerError(url, http_status, msg) + # Successfully returned data + break + + # In case the server returned a new session cookie to us + new_session = response.cookies.get(self.session_name, '') + + try: + data = response.json + # Compatibility with newer python-requests + if callable(data): + data = data() + except ValueError, e: + # The response wasn't JSON data + raise ServerError(url, http_status, b_('Error returned from' + ' json module while processing %(url)s: %(err)s') % + {'url': to_bytes(url), 'err': to_bytes(e)}) + + if 'exc' in data: + name = data.pop('exc') + message = data.pop('tg_flash') + raise AppError(name=name, message=message, extras=data) + + # If we need to return a cookie for deprecated code, convert it here + if self.session_as_cookie: + cookie = Cookie.SimpleCookie() + cookie[self.session_name] = new_session + new_session = cookie + + self.log.debug(b_('proxyclient.send_request: exited')) + data = bunchify(data) + return new_session, data + +__all__ = (ProxyClient,) diff --git a/tasks/fas_client.yml b/tasks/fas_client.yml index fda101a918..118c38c7fc 100644 --- a/tasks/fas_client.yml +++ b/tasks/fas_client.yml @@ -16,6 +16,16 @@ tags: - packages +- name: hotfix - python-fedora proxyclient.py + copy: > + src=$files/hotfix/python-fedora/proxyclient.py + dest=/usr/lib/python2.6/site-packages/fedora/client/proxyclient.py + owner=root mode=644 + tags: + - hotfix + - packages + + - name: install nss_db on rhel hosts only action: yum state=installed name=nss_db only_if: "'${ansible_distribution}' == 'RedHat'"