From 191748a403e1e38d6cf643d210a7fd9de3a7fc11 Mon Sep 17 00:00:00 2001
From: Darren Hague <d.hague@sap.com>
Date: Wed, 9 Nov 2016 18:47:24 +0000
Subject: [PATCH] Enable Basic and https certificate authentication for http
 publisher

Change-Id: I3735db7a37eff9e822e5180349eb8f86002fd0ef
---
 ceilometer/publisher/http.py                  | 59 ++++++++++++++++---
 ceilometer/tests/unit/publisher/test_http.py  | 21 +++++++
 ...isher-authentication-6371c5a9aa8d4c03.yaml | 14 +++++
 setup.cfg                                     |  2 +
 4 files changed, 87 insertions(+), 9 deletions(-)
 create mode 100644 releasenotes/notes/http-publisher-authentication-6371c5a9aa8d4c03.yaml

diff --git a/ceilometer/publisher/http.py b/ceilometer/publisher/http.py
index 27d256bef4..2f3602eb9a 100644
--- a/ceilometer/publisher/http.py
+++ b/ceilometer/publisher/http.py
@@ -35,9 +35,20 @@ class HttpPublisher(publisher.ConfigPublisherBase):
     `timeout` and `max_retries` will be set to 5 and 2 respectively. Additional
     parameters are:
 
-        - ssl can be enabled by setting `verify_ssl`
+        - ssl certificate verification can be disabled by setting `verify_ssl`
+          to False
         - batching can be configured by `batch`
         - connection pool size configured using `poolsize`
+        - Basic authentication can be configured using the URL authentication
+          scheme: http://username:password@example.com
+        - For certificate authentication, `clientcert` and `clientkey` are the
+          paths to the certificate and key files respectively. `clientkey` is
+          only required if the clientcert file doesn't already contain the key.
+
+    All of the parameters mentioned above get removed during processing,
+    with the remaining portion of the URL being used as the actual endpoint.
+    e.g. https://username:password@example.com/path?verify_ssl=False&q=foo
+    will result in a call to https://example.com/path?q=foo
 
     To use this publisher for samples, add the following section to the
     /etc/ceilometer/pipeline.yaml file or simply add it to an existing
@@ -63,7 +74,6 @@ class HttpPublisher(publisher.ConfigPublisherBase):
 
     def __init__(self, conf, parsed_url):
         super(HttpPublisher, self).__init__(conf, parsed_url)
-        self.target = parsed_url.geturl()
 
         if not parsed_url.hostname:
             raise ValueError('The hostname of an endpoint for '
@@ -83,12 +93,32 @@ class HttpPublisher(publisher.ConfigPublisherBase):
         self.poster = (
             self._do_post if strutils.bool_from_string(self._get_param(
                 params, 'batch', True)) else self._individual_post)
+        verify_ssl = self._get_param(params, 'verify_ssl', True)
         try:
-            self.verify_ssl = strutils.bool_from_string(
-                self._get_param(params, 'verify_ssl', None), strict=True)
+            self.verify_ssl = strutils.bool_from_string(verify_ssl,
+                                                        strict=True)
         except ValueError:
-            self.verify_ssl = (self._get_param(params, 'verify_ssl', None)
-                               or True)
+            self.verify_ssl = (verify_ssl or True)
+
+        username = parsed_url.username
+        password = parsed_url.password
+        if username:
+            self.client_auth = (username, password)
+            netloc = parsed_url.netloc.replace(username+':'+password+'@', '')
+        else:
+            self.client_auth = None
+            netloc = parsed_url.netloc
+
+        clientcert = self._get_param(params, 'clientcert', None)
+        clientkey = self._get_param(params, 'clientkey', None)
+        if clientcert:
+            if clientkey:
+                self.client_cert = (clientcert, clientkey)
+            else:
+                self.client_cert = clientcert
+        else:
+            self.client_cert = None
+
         self.raw_only = strutils.bool_from_string(
             self._get_param(params, 'raw_only', False))
 
@@ -96,7 +126,16 @@ class HttpPublisher(publisher.ConfigPublisherBase):
         kwargs = {'max_retries': self.max_retries,
                   'pool_connections': pool_size, 'pool_maxsize': pool_size}
         self.session = requests.Session()
-        # FIXME(gordc): support https in addition to http
+
+        # authentication & config params have been removed, so use URL with
+        # updated query string
+        self.target = urlparse.urlunsplit([
+            parsed_url.scheme,
+            netloc,
+            parsed_url.path,
+            urlparse.urlencode(params),
+            parsed_url.fragment])
+
         self.session.mount(self.target, adapters.HTTPAdapter(**kwargs))
 
         LOG.debug('HttpPublisher for endpoint %s is initialized!' %
@@ -105,8 +144,8 @@ class HttpPublisher(publisher.ConfigPublisherBase):
     @staticmethod
     def _get_param(params, name, default_value, cast=None):
         try:
-            return cast(params.get(name)[-1]) if cast else params.get(name)[-1]
-        except (ValueError, TypeError):
+            return cast(params.pop(name)[-1]) if cast else params.pop(name)[-1]
+        except (ValueError, TypeError, KeyError):
             LOG.debug('Default value %(value)s is used for %(name)s' %
                       {'value': default_value, 'name': name})
             return default_value
@@ -124,6 +163,8 @@ class HttpPublisher(publisher.ConfigPublisherBase):
         try:
             res = self.session.post(self.target, data=data,
                                     headers=self.headers, timeout=self.timeout,
+                                    auth=self.client_auth,
+                                    cert=self.client_cert,
                                     verify=self.verify_ssl)
             res.raise_for_status()
             LOG.debug('Message posting to %s: status code %d.',
diff --git a/ceilometer/tests/unit/publisher/test_http.py b/ceilometer/tests/unit/publisher/test_http.py
index 9ea49d7275..c340bc36c8 100644
--- a/ceilometer/tests/unit/publisher/test_http.py
+++ b/ceilometer/tests/unit/publisher/test_http.py
@@ -232,6 +232,27 @@ class TestHttpPublisher(base.BaseTestCase):
             publisher.publish_samples(self.sample_data)
             self.assertEqual('/path/to/cert.crt', post.call_args[1]['verify'])
 
+    def test_post_basic_auth(self):
+        parsed_url = urlparse.urlparse(
+            'http://alice:l00kingGla$$@localhost:90/path1?')
+        publisher = http.HttpPublisher(self.CONF, parsed_url)
+
+        with mock.patch.object(requests.Session, 'post') as post:
+            publisher.publish_samples(self.sample_data)
+            self.assertEqual(('alice', 'l00kingGla$$'),
+                             post.call_args[1]['auth'])
+
+    def test_post_client_cert_auth(self):
+        parsed_url = urlparse.urlparse('http://localhost:90/path1?'
+                                       'clientcert=/path/to/cert.crt&'
+                                       'clientkey=/path/to/cert.key')
+        publisher = http.HttpPublisher(self.CONF, parsed_url)
+
+        with mock.patch.object(requests.Session, 'post') as post:
+            publisher.publish_samples(self.sample_data)
+            self.assertEqual(('/path/to/cert.crt', '/path/to/cert.key'),
+                             post.call_args[1]['cert'])
+
     def test_post_raw_only(self):
         parsed_url = urlparse.urlparse('http://localhost:90/path1?raw_only=1')
         publisher = http.HttpPublisher(self.CONF, parsed_url)
diff --git a/releasenotes/notes/http-publisher-authentication-6371c5a9aa8d4c03.yaml b/releasenotes/notes/http-publisher-authentication-6371c5a9aa8d4c03.yaml
new file mode 100644
index 0000000000..807ea6dd23
--- /dev/null
+++ b/releasenotes/notes/http-publisher-authentication-6371c5a9aa8d4c03.yaml
@@ -0,0 +1,14 @@
+---
+features:
+  - In the 'publishers' section of a meter/event pipeline definition, https://
+    can now be used in addition to http://. Furthermore, either Basic or
+    client-certificate authentication can be used (obviously, client cert only
+    makes sense in the https case).
+    For Basic authentication, use the form http://username:password@hostname/.
+    For client certificate authentication pass the client certificate's path
+    (and the key file path, if the key is not in the certificate file) using
+    the parameters 'clientcert' and 'clientkey', e.g.
+    https://hostname/path?clientcert=/path/to/cert&clientkey=/path/to/key.
+    Any parameters or credentials used for http(s) publishers are removed from
+    the URL before the actual HTTP request is made.
+
diff --git a/setup.cfg b/setup.cfg
index 22a15bd84f..092a5f4667 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -231,6 +231,7 @@ ceilometer.sample.publisher =
     direct = ceilometer.publisher.direct:DirectPublisher
     kafka = ceilometer.publisher.kafka_broker:KafkaBrokerPublisher
     http = ceilometer.publisher.http:HttpPublisher
+    https = ceilometer.publisher.http:HttpPublisher
     gnocchi = ceilometer.publisher.direct:DirectPublisher
     database = ceilometer.publisher.direct:DirectPublisher
     file_alt = ceilometer.publisher.direct:DirectPublisher
@@ -242,6 +243,7 @@ ceilometer.event.publisher =
     notifier = ceilometer.publisher.messaging:EventNotifierPublisher
     kafka = ceilometer.publisher.kafka_broker:KafkaBrokerPublisher
     http = ceilometer.publisher.http:HttpPublisher
+    https = ceilometer.publisher.http:HttpPublisher
     gnocchi = ceilometer.publisher.direct:DirectPublisher
     database = ceilometer.publisher.direct:DirectPublisher
     file_alt = ceilometer.publisher.direct:DirectPublisher