]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
Authentication with Keystone.
authorSalvatore Orlando <salvatore.orlando@eu.citrix.com>
Thu, 11 Aug 2011 16:53:43 +0000 (17:53 +0100)
committerSalvatore Orlando <salvatore.orlando@eu.citrix.com>
Thu, 11 Aug 2011 16:53:43 +0000 (17:53 +0100)
auth_token Middleware tweaked and imported in Quantum tree
Developing Authorization middleware

etc/quantum.conf
quantum/cli.py
quantum/client.py
quantum/common/auth_token.py [new file with mode: 0755]
quantum/common/authorization.py [new file with mode: 0644]
quantum/common/bufferedhttp.py [new file with mode: 0644]

index ba96a9a275f7e1abd490d4dcc31827fd255cf249..193ce8b396bde847265a13c8fcc213e819144dd9 100644 (file)
@@ -16,10 +16,23 @@ use = egg:Paste#urlmap
 /: quantumversions
 /v0.1: quantumapi
 
+[filter:tokenauth]
+paste.filter_factory = quantum.common.auth_token:filter_factory
+auth_host = 127.0.0.1
+auth_port = 5001
+auth_protocol = http
+# Not sure the admin token thing is right...
+#admin_token = 9a82c95a-99e9-4c3a-b5ee-199f6ba7ff04
+admin_user = admin
+admin_password = secrete
+
+[pipeline:quantumapi]
+pipeline = tokenauth quantumapiapp
+
 [app:quantumversions]
 paste.app_factory = quantum.api.versions:Versions.factory
 
-[app:quantumapi]
+[app:quantumapiapp]
 paste.app_factory = quantum.api:APIRouterV01.factory
 
 
index a015565d872743addcd1ce6c9019c384cafbb92a..940416e8c2b6cd2a9dc5c4aa233b0d2af1e07634 100644 (file)
@@ -104,7 +104,9 @@ def detail_net(manager, *args):
 def api_detail_net(client, *args):
     tid, nid = args
     try:
-        res = client.list_network_details(nid)["networks"]["network"]
+        res = client.list_network_details(nid)["network"]
+        print "BLOODY MARY"
+        print res
     except Exception, e:
         LOG.error("Failed to get network details: %s" % e)
         return
index ca05236faaec0a621bff28e8d3f74bf4c16bd2ef..f021e2db0ba66ee25db10e90305f1546a065d8ab 100644 (file)
@@ -134,7 +134,9 @@ class Client(object):
 
             c.request(method, action, body, headers)
             res = c.getresponse()
+            print "RESPONSE RECEIVED"
             status_code = self.get_status_code(res)
+            print "STATUS CODE:%s" %status_code
             if status_code in (httplib.OK,
                                httplib.CREATED,
                                httplib.ACCEPTED,
diff --git a/quantum/common/auth_token.py b/quantum/common/auth_token.py
new file mode 100755 (executable)
index 0000000..c52dcec
--- /dev/null
@@ -0,0 +1,358 @@
+#!/usr/bin/env python
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright (c) 2010-2011 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+"""
+TOKEN-BASED AUTH MIDDLEWARE
+
+This WSGI component performs multiple jobs:
+- it verifies that incoming client requests have valid tokens by verifying
+    tokens with the auth service.
+- it will reject unauthenticated requests UNLESS it is in 'delay_auth_decision'
+    mode, which means the final decision is delegated to the downstream WSGI
+    component (usually the OpenStack service)
+- it will collect and forward identity information from a valid token
+    such as user name, groups, etc...
+
+Refer to: http://wiki.openstack.org/openstack-authn
+
+This WSGI component has been derived from Keystone's auth_token
+middleware module. It contains some specialization for Quantum.
+
+HEADERS
+-------
+Headers starting with HTTP_ is a standard http header
+Headers starting with HTTP_X is an extended http header
+
+> Coming in from initial call from client or customer
+HTTP_X_AUTH_TOKEN   : the client token being passed in
+HTTP_X_STORAGE_TOKEN: the client token being passed in (legacy Rackspace use)
+                      to support cloud files
+> Used for communication between components
+www-authenticate    : only used if this component is being used remotely
+HTTP_AUTHORIZATION  : basic auth password used to validate the connection
+
+> What we add to the request for use by the OpenStack service
+HTTP_X_AUTHORIZATION: the client identity being passed in
+
+"""
+
+import eventlet
+from eventlet import wsgi
+import httplib
+import json
+import logging
+import os
+from paste.deploy import loadapp
+from urlparse import urlparse
+from webob.exc import HTTPUnauthorized, HTTPUseProxy
+from webob.exc import Request, Response
+
+from quantum.common.bufferedhttp import http_connect_raw as http_connect
+
+PROTOCOL_NAME = "Token Authentication"
+LOG = logging.getLogger('quantum.common.auth_token')
+
+class AuthProtocol(object):
+    """Auth Middleware that handles authenticating client calls"""
+
+    def _init_protocol_common(self, app, conf):
+        """ Common initialization code"""
+        LOG.info("Starting the %s component", PROTOCOL_NAME)
+
+        self.conf = conf
+        self.app = app
+        #if app is set, then we are in a WSGI pipeline and requests get passed
+        # on to app. If it is not set, this component should forward requests
+
+        # where to find the Quantum service (if not in local WSGI chain)
+        # these settings are only used if this component is acting as a proxy
+        # and the OpenSTack service is running remotely
+        if not self.app:
+            self.service_protocol = conf.get('quantum_protocol', 'https')
+            self.service_host = conf.get('quantum_host')
+            self.service_port = int(conf.get('quantum_port'))
+            self.service_url = '%s://%s:%s' % (self.service_protocol,
+                                           self.service_host,
+                                           self.service_port)
+
+        # delay_auth_decision means we still allow unauthenticated requests
+        # through and we let the downstream service make the final decision
+        self.delay_auth_decision = int(conf.get('delay_auth_decision', 0))
+
+    def _init_protocol(self, app, conf):
+        """ Protocol specific initialization """
+
+        # where to find the auth service (we use this to validate tokens)
+        self.auth_host = conf.get('auth_host')
+        self.auth_port = int(conf.get('auth_port'))
+        self.auth_protocol = conf.get('auth_protocol', 'https')
+        self.auth_location = "%s://%s:%s" % (self.auth_protocol,
+                                             self.auth_host,
+                                             self.auth_port)
+        LOG.debug("AUTH SERVICE LOCATION:%s", self.auth_location)
+        # Credentials used to verify this component with the Auth service since
+        # validating tokens is a priviledged call
+        self.admin_user = conf.get('admin_user')
+        self.admin_password = conf.get('admin_password')
+        self.admin_token = conf.get('admin_token')
+
+    def __init__(self, app, conf):
+        """ Common initialization code """
+        #TODO(ziad): maybe we rafactor this into a superclass
+        self._init_protocol_common(app, conf)  # Applies to all protocols
+        self._init_protocol(app, conf)  # Specific to this protocol
+
+    def __call__(self, env, start_response):
+        """ Handle incoming request. Authenticate. And send downstream. """
+        LOG.debug("entering AuthProtocol.__call__")
+        LOG.debug("start response:%s", start_response)
+        self.start_response = start_response
+        self.env = env
+
+        #Prep headers to forward request to local or remote downstream service
+        self.proxy_headers = env.copy()
+        for header in self.proxy_headers.iterkeys():
+            if header[0:5] == 'HTTP_':
+                self.proxy_headers[header[5:]] = self.proxy_headers[header]
+                del self.proxy_headers[header]
+
+        #Look for authentication claims
+        LOG.debug("Looking for authentication claims")
+        self.claims = self._get_claims(env)
+        if not self.claims:
+            #No claim(s) provided
+            LOG.debug("No claims provided")
+            if self.delay_auth_decision:
+                #Configured to allow downstream service to make final decision.
+                #So mark status as Invalid and forward the request downstream
+                self._decorate_request("X_IDENTITY_STATUS", "Invalid")
+            else:
+                #Respond to client as appropriate for this auth protocol
+                return self._reject_request()
+        else:
+            # this request is presenting claims. Let's validate them
+            LOG.debug("Claims found. Validating.")
+            valid = self._validate_claims(self.claims)
+            if not valid:
+                # Keystone rejected claim
+                if self.delay_auth_decision:
+                    # Downstream service will receive call still and decide
+                    self._decorate_request("X_IDENTITY_STATUS", "Invalid")
+                else:
+                    #Respond to client as appropriate for this auth protocol
+                    return self._reject_claims()
+            else:
+                self._decorate_request("X_IDENTITY_STATUS", "Confirmed")
+
+            #Collect information about valid claims
+            if valid:
+                LOG.debug("Validation successful")
+                claims = self._expound_claims()
+
+                # Store authentication data
+                if claims:
+                    # TODO(Ziad): add additional details we may need,
+                    #             like tenant and group info
+                    self._decorate_request('X_AUTHORIZATION', "Proxy %s" % 
+                        claims['user'])
+                    self._decorate_request('X_TENANT', claims['tenant'])
+                    self._decorate_request('X_USER', claims['user'])
+                    if 'group' in claims:
+                        self._decorate_request('X_GROUP', claims['group'])
+                    if 'roles' in claims and len(claims['roles']) > 0:
+                        if claims['roles'] != None:
+                            roles = ''
+                            for role in claims['roles']:
+                                if len(roles) > 0:
+                                    roles += ','
+                                roles += role
+                            self._decorate_request('X_ROLE', roles)
+
+                    # NOTE(todd): unused
+                    self.expanded = True
+            LOG.debug("About to forward request")
+            #Send request downstream
+            return self._forward_request()
+
+    # NOTE(todd): unused
+    # NOTE(salvatore-orlando): temporarily used again
+    def get_admin_auth_token(self, username, password):
+        """
+        This function gets an admin auth token to be used by this service to
+        validate a user's token. Validate_token is a priviledged call so
+        it needs to be authenticated by a service that is calling it
+        """
+        headers = {"Content-type": "application/json", "Accept": "text/json"}
+        params = {"passwordCredentials": {"username": username,
+                                          "password": password}}
+        conn = httplib.HTTPConnection("%s:%s" \
+            % (self.auth_host, self.auth_port))
+        conn.request("POST", "/v2.0/tokens", json.dumps(params), \
+            headers=headers)
+        response = conn.getresponse()
+        data = response.read()
+        return data
+
+    def _get_claims(self, env):
+        """Get claims from request"""
+        claims = env.get('HTTP_X_AUTH_TOKEN', env.get('HTTP_X_STORAGE_TOKEN'))
+        return claims
+
+    def _reject_request(self):
+        """Redirect client to auth server"""
+        return HTTPUseProxy(location=self.auth_location)(self.env,
+            self.start_response)
+
+    def _reject_claims(self):
+        """Client sent bad claims"""
+        return HTTPUnauthorized()(self.env,
+            self.start_response)
+
+    def _validate_claims(self, claims):
+        """Validate claims, and provide identity information if applicable """
+
+        # Step 1: We need to auth with the keystone service, so get an
+        # admin token
+        #TODO(ziad): Need to properly implement this, where to store creds
+        # for now using token from ini        
+        #TODO(salvatore-orlando): Temporarily restoring auth token retrieval, 
+        # with credentials in configuration file
+        if not self.admin_token: 
+            auth = self.get_admin_auth_token(self.admin_user,
+                                             self.admin_password)
+            LOG.debug("ADMIN TOKEN?%s",json.loads(auth))
+            self.admin_token = json.loads(auth)["auth"]["token"]["id"]
+
+        # Step 2: validate the user's token with the auth service
+        # since this is a priviledged op,m we need to auth ourselves
+        # by using an admin token
+        headers = {"Content-type": "application/json",
+                    "Accept": "text/json",
+                    "X-Auth-Token": self.admin_token}
+                    ##TODO(ziad):we need to figure out how to auth to keystone
+                    #since validate_token is a priviledged call
+                    #Khaled's version uses creds to get a token
+                    # "X-Auth-Token": admin_token}
+                    # we're using a test token from the ini file for now
+        conn = http_connect(self.auth_host, self.auth_port, 'GET',
+                            '/v2.0/tokens/%s' % claims, headers=headers)
+        resp = conn.getresponse()
+        # data = resp.read()
+        conn.close()
+
+        if not str(resp.status).startswith('20'):
+            # Keystone rejected claim
+            return False
+        else:
+            #TODO(Ziad): there is an optimization we can do here. We have just
+            #received data from Keystone that we can use instead of making
+            #another call in _expound_claims
+            return True
+
+    def _expound_claims(self):
+        # Valid token. Get user data and put it in to the call
+        # so the downstream service can use it
+        headers = {"Content-type": "application/json",
+                    "Accept": "text/json",
+                    "X-Auth-Token": self.admin_token}
+                    ##TODO(ziad):we need to figure out how to auth to keystone
+                    #since validate_token is a priviledged call
+                    #Khaled's version uses creds to get a token
+                    # "X-Auth-Token": admin_token}
+                    # we're using a test token from the ini file for now
+        conn = http_connect(self.auth_host, self.auth_port, 'GET',
+                            '/v2.0/tokens/%s' % self.claims, headers=headers)
+        resp = conn.getresponse()
+        data = resp.read()
+        conn.close()
+
+        if not str(resp.status).startswith('20'):
+            raise LookupError('Unable to locate claims: %s' % resp.status)
+
+        token_info = json.loads(data)
+        #TODO(Ziad): make this more robust
+        #first_group = token_info['auth']['user']['groups']['group'][0]
+        roles = []
+        role_refs = token_info["auth"]["user"]["roleRefs"]
+        if role_refs != None:
+            for role_ref in role_refs:
+                roles.append(role_ref["roleId"])
+
+        verified_claims = {'user': token_info['auth']['user']['username'],
+                    'tenant': token_info['auth']['user']['tenantId'],
+                    'roles': roles}
+
+        # TODO(Ziad): removed groups for now
+        #            ,'group': '%s/%s' % (first_group['id'],
+        #                                first_group['tenantId'])}
+        return verified_claims
+
+    def _decorate_request(self, index, value):
+        """Add headers to request"""
+        self.proxy_headers[index] = value
+        self.env["HTTP_%s" % index] = value
+
+    def _forward_request(self):
+        """Token/Auth processed & claims added to headers"""
+        #now decide how to pass on the call
+        if self.app:
+            # Pass to downstream WSGI component
+            return self.app(self.env, self.start_response)
+            #.custom_start_response)
+        else:
+            # We are forwarding to a remote service (no downstream WSGI app)
+            req = Request(self.proxy_headers)
+            parsed = urlparse(req.url)
+            conn = http_connect(self.service_host,
+                                self.service_port,
+                                req.method,
+                                parsed.path,
+                                self.proxy_headers,
+                                ssl=(self.service_protocol == 'https'))
+            resp = conn.getresponse()
+            data = resp.read()
+            #TODO(ziad): use a more sophisticated proxy
+            # we are rewriting the headers now
+            return Response(status=resp.status, body=data)(self.proxy_headers,
+                                                           self.start_response)
+
+
+def filter_factory(global_conf, **local_conf):
+    """Returns a WSGI filter app for use with paste.deploy."""
+    conf = global_conf.copy()
+    conf.update(local_conf)
+
+    def auth_filter(app):
+        return AuthProtocol(app, conf)
+    return auth_filter
+
+
+def app_factory(global_conf, **local_conf):
+    conf = global_conf.copy()
+    conf.update(local_conf)
+    return AuthProtocol(None, conf)
+
+if __name__ == "__main__":
+    app = loadapp("config:" + \
+        os.path.join(os.path.abspath(os.path.dirname(__file__)),
+                     os.pardir,
+                     os.pardir,
+                    "examples/paste/auth_token.ini"),
+                    global_conf={"log_name": "auth_token.log"})
+    wsgi.server(eventlet.listen(('', 8090)), app)
diff --git a/quantum/common/authorization.py b/quantum/common/authorization.py
new file mode 100644 (file)
index 0000000..30a90c4
--- /dev/null
@@ -0,0 +1,84 @@
+#!/usr/bin/env python
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright (c) 2010-2011 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+""" Middleware for authorizing Quantum Operations
+    This is a first and very trivial implementation of a middleware
+    for authorizing requests to Quantum API. 
+    It only verifies that the tenant requesting the operation owns the
+    network (and thus the port) on which it is going to operate.
+    It also verifies, if the operation involves an interface, that
+    the tenant owns that interface by querying an API on the service
+    where the interface is defined. 
+"""
+import logging
+
+from webob.exc import HTTPUnauthorized, HTTPForbidden
+from webob.exc import Request, Response
+
+LOG = logging.getLogger('quantum.common.authorization')
+
+class QuantumAuthorization(object):
+    """ Authorizes an operation before it reaches the API WSGI app"""
+
+    def __call__(self, req, start_response):
+        """ Handle incoming request. Authorize. And send downstream. """
+        LOG.debug("entering QuantumAuthorization.__call__")
+        self.start_response = start_response
+        self.req = req
+
+        # Retrieves TENANT ID from headers as the request 
+        # should already have been authenticated with Keystone
+        self.headers = req.copy()
+        if not "X_TENANT" in self.headers:
+            # This is bad, very bad
+            self._reject()
+        
+        auth_tenant_id = self.headers['X_TENANT']
+        path = self.req.path
+        parts=path.split('/')
+        #TODO (salvatore-orlando): need bound checking here
+        idx = parts.index('tenants') + 1
+        req_tenant_id = parts[idx]
+        
+        if auth_tenant_id != req_tenant_id:
+            # This is bad, very bad
+            self._forbid()
+        
+        # Okay, authorize it!
+        
+    def _reject(self):
+        """Apparently the request has not been authenticated """
+        return HTTPUnauthorized()(self.env,
+            self.start_response)
+    
+    
+    def _forbid(self):
+        """Cannot authorize. Operating on non-owned resources"""
+        return HTTPForbidden()(self.env,
+            self.start_response)
+        
+        
+    
+def filter_factory(global_conf, **local_conf):
+    """Returns a WSGI filter app for use with paste.deploy."""
+    conf = global_conf.copy()
+    conf.update(local_conf)
+
+    def authz_filter(app):
+        return QuantumAuthorization(app, conf)
+    return authz_filter
diff --git a/quantum/common/bufferedhttp.py b/quantum/common/bufferedhttp.py
new file mode 100644 (file)
index 0000000..fdb35ee
--- /dev/null
@@ -0,0 +1,165 @@
+# Copyright (c) 2010-2011 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Monkey Patch httplib.HTTPResponse to buffer reads of headers. This can improve
+performance when making large numbers of small HTTP requests.  This module
+also provides helper functions to make HTTP connections using
+BufferedHTTPResponse.
+
+.. warning::
+
+    If you use this, be sure that the libraries you are using do not access
+    the socket directly (xmlrpclib, I'm looking at you :/), and instead
+    make all calls through httplib.
+"""
+
+from urllib import quote
+import logging
+import time
+
+from eventlet.green.httplib import CONTINUE, HTTPConnection, HTTPMessage, \
+    HTTPResponse, HTTPSConnection, _UNKNOWN
+
+
+class BufferedHTTPResponse(HTTPResponse):
+    """HTTPResponse class that buffers reading of headers"""
+
+    def __init__(self, sock, debuglevel=0, strict=0,
+                 method=None):          # pragma: no cover
+        self.sock = sock
+        self.fp = sock.makefile('rb')
+        self.debuglevel = debuglevel
+        self.strict = strict
+        self._method = method
+
+        self.msg = None
+
+        # from the Status-Line of the response
+        self.version = _UNKNOWN         # HTTP-Version
+        self.status = _UNKNOWN          # Status-Code
+        self.reason = _UNKNOWN          # Reason-Phrase
+
+        self.chunked = _UNKNOWN         # is "chunked" being used?
+        self.chunk_left = _UNKNOWN      # bytes left to read in current chunk
+        self.length = _UNKNOWN          # number of bytes left in response
+        self.will_close = _UNKNOWN      # conn will close at end of response
+
+    def expect_response(self):
+        self.fp = self.sock.makefile('rb', 0)
+        version, status, reason = self._read_status()
+        if status != CONTINUE:
+            self._read_status = lambda: (version, status, reason)
+            self.begin()
+        else:
+            self.status = status
+            self.reason = reason.strip()
+            self.version = 11
+            self.msg = HTTPMessage(self.fp, 0)
+            self.msg.fp = None
+
+
+class BufferedHTTPConnection(HTTPConnection):
+    """HTTPConnection class that uses BufferedHTTPResponse"""
+    response_class = BufferedHTTPResponse
+
+    def connect(self):
+        self._connected_time = time.time()
+        return HTTPConnection.connect(self)
+
+    def putrequest(self, method, url, skip_host=0, skip_accept_encoding=0):
+        self._method = method
+        self._path = url
+        return HTTPConnection.putrequest(self, method, url, skip_host,
+                                         skip_accept_encoding)
+
+    def getexpect(self):
+        response = BufferedHTTPResponse(self.sock, strict=self.strict,
+                                       method=self._method)
+        response.expect_response()
+        return response
+
+    def getresponse(self):
+        response = HTTPConnection.getresponse(self)
+        logging.debug(("HTTP PERF: %(time).5f seconds to %(method)s "
+                        "%(host)s:%(port)s %(path)s)"),
+           {'time': time.time() - self._connected_time, 'method': self._method,
+            'host': self.host, 'port': self.port, 'path': self._path})
+        return response
+
+
+def http_connect(ipaddr, port, device, partition, method, path,
+                 headers=None, query_string=None, ssl=False):
+    """
+    Helper function to create an HTTPConnection object. If ssl is set True,
+    HTTPSConnection will be used. However, if ssl=False, BufferedHTTPConnection
+    will be used, which is buffered for backend Swift services.
+
+    :param ipaddr: IPv4 address to connect to
+    :param port: port to connect to
+    :param device: device of the node to query
+    :param partition: partition on the device
+    :param method: HTTP method to request ('GET', 'PUT', 'POST', etc.)
+    :param path: request path
+    :param headers: dictionary of headers
+    :param query_string: request query string
+    :param ssl: set True if SSL should be used (default: False)
+    :returns: HTTPConnection object
+    """
+    if ssl:
+        conn = HTTPSConnection('%s:%s' % (ipaddr, port))
+    else:
+        conn = BufferedHTTPConnection('%s:%s' % (ipaddr, port))
+    path = quote('/' + device + '/' + str(partition) + path)
+    if query_string:
+        path += '?' + query_string
+    conn.path = path
+    conn.putrequest(method, path)
+    if headers:
+        for header, value in headers.iteritems():
+            conn.putheader(header, value)
+    conn.endheaders()
+    return conn
+
+
+def http_connect_raw(ipaddr, port, method, path, headers=None,
+                     query_string=None, ssl=False):
+    """
+    Helper function to create an HTTPConnection object. If ssl is set True,
+    HTTPSConnection will be used. However, if ssl=False, BufferedHTTPConnection
+    will be used, which is buffered for backend Swift services.
+
+    :param ipaddr: IPv4 address to connect to
+    :param port: port to connect to
+    :param method: HTTP method to request ('GET', 'PUT', 'POST', etc.)
+    :param path: request path
+    :param headers: dictionary of headers
+    :param query_string: request query string
+    :param ssl: set True if SSL should be used (default: False)
+    :returns: HTTPConnection object
+    """
+    if ssl:
+        conn = HTTPSConnection('%s:%s' % (ipaddr, port))
+    else:
+        conn = BufferedHTTPConnection('%s:%s' % (ipaddr, port))
+    if query_string:
+        path += '?' + query_string
+    conn.path = path
+    conn.putrequest(method, path)
+    if headers:
+        for header, value in headers.iteritems():
+            conn.putheader(header, value)
+    conn.endheaders()
+    return conn