BigSwitch: Add SSL Certificate Validation
This patch adds the option to use SSL certificate validation on the backend controller using SSH-style sticky authentication, individual trusted certificates, and/or certificate authorities. Also adds caching of connections to deal with increased overhead of TLS/SSL handshake. Default is now sticky-style enforcement. Partial-Bug: 1188189 Implements: blueprint bsn-certificate-enforcement Change-Id: If0bab196495c4944a53e0e394c956cca36269883
This commit is contained in:
parent
eb7de12def
commit
7255e05609
@ -6,7 +6,10 @@
|
|||||||
# The following parameters are supported:
|
# The following parameters are supported:
|
||||||
# servers : <host:port>[,<host:port>]* (Error if not set)
|
# servers : <host:port>[,<host:port>]* (Error if not set)
|
||||||
# server_auth : <username:password> (default: no auth)
|
# server_auth : <username:password> (default: no auth)
|
||||||
# server_ssl : True | False (default: False)
|
# server_ssl : True | False (default: True)
|
||||||
|
# ssl_cert_directory : <path> (default: /etc/neutron/plugins/bigswitch/ssl)
|
||||||
|
# no_ssl_validation : True | False (default: False)
|
||||||
|
# ssl_sticky : True | False (default: True)
|
||||||
# sync_data : True | False (default: False)
|
# sync_data : True | False (default: False)
|
||||||
# auto_sync_on_failure : True | False (default: True)
|
# auto_sync_on_failure : True | False (default: True)
|
||||||
# server_timeout : <integer> (default: 10 seconds)
|
# server_timeout : <integer> (default: 10 seconds)
|
||||||
@ -21,7 +24,20 @@ servers=localhost:8080
|
|||||||
# server_auth=username:password
|
# server_auth=username:password
|
||||||
|
|
||||||
# Use SSL when connecting to the BigSwitch or Floodlight controller.
|
# Use SSL when connecting to the BigSwitch or Floodlight controller.
|
||||||
# server_ssl=False
|
# server_ssl=True
|
||||||
|
|
||||||
|
# Directory which contains the ca_certs and host_certs to be used to validate
|
||||||
|
# controller certificates.
|
||||||
|
# ssl_cert_directory=/etc/neutron/plugins/bigswitch/ssl/
|
||||||
|
|
||||||
|
# If a certificate does not exist for a controller, trust and store the first
|
||||||
|
# certificate received for that controller and use it to validate future
|
||||||
|
# connections to that controller.
|
||||||
|
# ssl_sticky=True
|
||||||
|
|
||||||
|
# Do not validate the controller certificates for SSL
|
||||||
|
# Warning: This will not provide protection against man-in-the-middle attacks
|
||||||
|
# no_ssl_validation=False
|
||||||
|
|
||||||
# Sync data on connect
|
# Sync data on connect
|
||||||
# sync_data=False
|
# sync_data=False
|
||||||
|
3
etc/neutron/plugins/bigswitch/ssl/ca_certs/README
Normal file
3
etc/neutron/plugins/bigswitch/ssl/ca_certs/README
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Certificates in this folder will be used to
|
||||||
|
verify signatures for any controllers the plugin
|
||||||
|
connects to.
|
6
etc/neutron/plugins/bigswitch/ssl/host_certs/README
Normal file
6
etc/neutron/plugins/bigswitch/ssl/host_certs/README
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
Certificates in this folder must match the name
|
||||||
|
of the controller they should be used to authenticate
|
||||||
|
with a .pem extension.
|
||||||
|
|
||||||
|
For example, the certificate for the controller
|
||||||
|
"192.168.0.1" should be named "192.168.0.1.pem".
|
@ -39,9 +39,21 @@ restproxy_opts = [
|
|||||||
cfg.StrOpt('server_auth', default=None, secret=True,
|
cfg.StrOpt('server_auth', default=None, secret=True,
|
||||||
help=_("The username and password for authenticating against "
|
help=_("The username and password for authenticating against "
|
||||||
" the BigSwitch or Floodlight controller.")),
|
" the BigSwitch or Floodlight controller.")),
|
||||||
cfg.BoolOpt('server_ssl', default=False,
|
cfg.BoolOpt('server_ssl', default=True,
|
||||||
help=_("If True, Use SSL when connecting to the BigSwitch or "
|
help=_("If True, Use SSL when connecting to the BigSwitch or "
|
||||||
"Floodlight controller.")),
|
"Floodlight controller.")),
|
||||||
|
cfg.BoolOpt('ssl_sticky', default=True,
|
||||||
|
help=_("Trust and store the first certificate received for "
|
||||||
|
"each controller address and use it to validate future "
|
||||||
|
"connections to that address.")),
|
||||||
|
cfg.BoolOpt('no_ssl_validation', default=False,
|
||||||
|
help=_("Disables SSL certificate validation for controllers")),
|
||||||
|
cfg.BoolOpt('cache_connections', default=True,
|
||||||
|
help=_("Re-use HTTP/HTTPS connections to the controller.")),
|
||||||
|
cfg.StrOpt('ssl_cert_directory',
|
||||||
|
default='/etc/neutron/plugins/bigswitch/ssl',
|
||||||
|
help=_("Directory containing ca_certs and host_certs "
|
||||||
|
"certificate directories.")),
|
||||||
cfg.BoolOpt('sync_data', default=False,
|
cfg.BoolOpt('sync_data', default=False,
|
||||||
help=_("Sync data on connect")),
|
help=_("Sync data on connect")),
|
||||||
cfg.BoolOpt('auto_sync_on_failure', default=True,
|
cfg.BoolOpt('auto_sync_on_failure', default=True,
|
||||||
|
@ -27,13 +27,16 @@ of ServerProxy objects that correspond to individual backend controllers.
|
|||||||
The following functionality is handled by this module:
|
The following functionality is handled by this module:
|
||||||
- Translation of rest_* function calls to HTTP/HTTPS calls to the controllers
|
- Translation of rest_* function calls to HTTP/HTTPS calls to the controllers
|
||||||
- Automatic failover between controllers
|
- Automatic failover between controllers
|
||||||
|
- SSL Certificate enforcement
|
||||||
- HTTP Authentication
|
- HTTP Authentication
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import httplib
|
import httplib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import eventlet
|
import eventlet
|
||||||
@ -41,6 +44,7 @@ from oslo.config import cfg
|
|||||||
|
|
||||||
from neutron.common import exceptions
|
from neutron.common import exceptions
|
||||||
from neutron.common import utils
|
from neutron.common import utils
|
||||||
|
from neutron.openstack.common import excutils
|
||||||
from neutron.openstack.common import log as logging
|
from neutron.openstack.common import log as logging
|
||||||
from neutron.plugins.bigswitch.db import consistency_db as cdb
|
from neutron.plugins.bigswitch.db import consistency_db as cdb
|
||||||
|
|
||||||
@ -85,7 +89,7 @@ class ServerProxy(object):
|
|||||||
"""REST server proxy to a network controller."""
|
"""REST server proxy to a network controller."""
|
||||||
|
|
||||||
def __init__(self, server, port, ssl, auth, neutron_id, timeout,
|
def __init__(self, server, port, ssl, auth, neutron_id, timeout,
|
||||||
base_uri, name, mypool):
|
base_uri, name, mypool, combined_cert):
|
||||||
self.server = server
|
self.server = server
|
||||||
self.port = port
|
self.port = port
|
||||||
self.ssl = ssl
|
self.ssl = ssl
|
||||||
@ -99,8 +103,11 @@ class ServerProxy(object):
|
|||||||
self.capabilities = []
|
self.capabilities = []
|
||||||
# enable server to reference parent pool
|
# enable server to reference parent pool
|
||||||
self.mypool = mypool
|
self.mypool = mypool
|
||||||
|
# cache connection here to avoid a SSL handshake for every connection
|
||||||
|
self.currentconn = None
|
||||||
if auth:
|
if auth:
|
||||||
self.auth = 'Basic ' + base64.encodestring(auth).strip()
|
self.auth = 'Basic ' + base64.encodestring(auth).strip()
|
||||||
|
self.combined_cert = combined_cert
|
||||||
|
|
||||||
def get_capabilities(self):
|
def get_capabilities(self):
|
||||||
try:
|
try:
|
||||||
@ -114,7 +121,8 @@ class ServerProxy(object):
|
|||||||
'cap': self.capabilities})
|
'cap': self.capabilities})
|
||||||
return self.capabilities
|
return self.capabilities
|
||||||
|
|
||||||
def rest_call(self, action, resource, data='', headers={}, timeout=None):
|
def rest_call(self, action, resource, data='', headers={}, timeout=False,
|
||||||
|
reconnect=False):
|
||||||
uri = self.base_uri + resource
|
uri = self.base_uri + resource
|
||||||
body = json.dumps(data)
|
body = json.dumps(data)
|
||||||
if not headers:
|
if not headers:
|
||||||
@ -125,6 +133,10 @@ class ServerProxy(object):
|
|||||||
headers['Instance-ID'] = self.neutron_id
|
headers['Instance-ID'] = self.neutron_id
|
||||||
headers['Orchestration-Service-ID'] = ORCHESTRATION_SERVICE_ID
|
headers['Orchestration-Service-ID'] = ORCHESTRATION_SERVICE_ID
|
||||||
headers[HASH_MATCH_HEADER] = self.mypool.consistency_hash
|
headers[HASH_MATCH_HEADER] = self.mypool.consistency_hash
|
||||||
|
if 'keep-alive' in self.capabilities:
|
||||||
|
headers['Connection'] = 'keep-alive'
|
||||||
|
else:
|
||||||
|
reconnect = True
|
||||||
if self.auth:
|
if self.auth:
|
||||||
headers['Authorization'] = self.auth
|
headers['Authorization'] = self.auth
|
||||||
|
|
||||||
@ -136,26 +148,37 @@ class ServerProxy(object):
|
|||||||
{'resource': resource, 'data': data, 'headers': headers,
|
{'resource': resource, 'data': data, 'headers': headers,
|
||||||
'action': action})
|
'action': action})
|
||||||
|
|
||||||
conn = None
|
# unspecified timeout is False because a timeout can be specified as
|
||||||
timeout = timeout or self.timeout
|
# None to indicate no timeout.
|
||||||
if self.ssl:
|
if timeout is False:
|
||||||
conn = httplib.HTTPSConnection(
|
timeout = self.timeout
|
||||||
self.server, self.port, timeout=timeout)
|
|
||||||
if conn is None:
|
if timeout != self.timeout:
|
||||||
LOG.error(_('ServerProxy: Could not establish HTTPS '
|
# need a new connection if timeout has changed
|
||||||
'connection'))
|
reconnect = True
|
||||||
return 0, None, None, None
|
|
||||||
else:
|
if not self.currentconn or reconnect:
|
||||||
conn = httplib.HTTPConnection(
|
if self.currentconn:
|
||||||
self.server, self.port, timeout=timeout)
|
self.currentconn.close()
|
||||||
if conn is None:
|
if self.ssl:
|
||||||
LOG.error(_('ServerProxy: Could not establish HTTP '
|
self.currentconn = HTTPSConnectionWithValidation(
|
||||||
'connection'))
|
self.server, self.port, timeout=timeout)
|
||||||
return 0, None, None, None
|
self.currentconn.combined_cert = self.combined_cert
|
||||||
|
if self.currentconn is None:
|
||||||
|
LOG.error(_('ServerProxy: Could not establish HTTPS '
|
||||||
|
'connection'))
|
||||||
|
return 0, None, None, None
|
||||||
|
else:
|
||||||
|
self.currentconn = httplib.HTTPConnection(
|
||||||
|
self.server, self.port, timeout=timeout)
|
||||||
|
if self.currentconn is None:
|
||||||
|
LOG.error(_('ServerProxy: Could not establish HTTP '
|
||||||
|
'connection'))
|
||||||
|
return 0, None, None, None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn.request(action, uri, body, headers)
|
self.currentconn.request(action, uri, body, headers)
|
||||||
response = conn.getresponse()
|
response = self.currentconn.getresponse()
|
||||||
newhash = response.getheader(HASH_MATCH_HEADER)
|
newhash = response.getheader(HASH_MATCH_HEADER)
|
||||||
if newhash:
|
if newhash:
|
||||||
self._put_consistency_hash(newhash)
|
self._put_consistency_hash(newhash)
|
||||||
@ -168,11 +191,20 @@ class ServerProxy(object):
|
|||||||
# response was not JSON, ignore the exception
|
# response was not JSON, ignore the exception
|
||||||
pass
|
pass
|
||||||
ret = (response.status, response.reason, respstr, respdata)
|
ret = (response.status, response.reason, respstr, respdata)
|
||||||
|
except httplib.ImproperConnectionState:
|
||||||
|
# If we were using a cached connection, try again with a new one.
|
||||||
|
with excutils.save_and_reraise_exception() as ctxt:
|
||||||
|
if not reconnect:
|
||||||
|
ctxt.reraise = False
|
||||||
|
|
||||||
|
if self.currentconn:
|
||||||
|
self.currentconn.close()
|
||||||
|
return self.rest_call(action, resource, data, headers,
|
||||||
|
timeout=timeout, reconnect=True)
|
||||||
except (socket.timeout, socket.error) as e:
|
except (socket.timeout, socket.error) as e:
|
||||||
LOG.error(_('ServerProxy: %(action)s failure, %(e)r'),
|
LOG.error(_('ServerProxy: %(action)s failure, %(e)r'),
|
||||||
{'action': action, 'e': e})
|
{'action': action, 'e': e})
|
||||||
ret = 0, None, None, None
|
ret = 0, None, None, None
|
||||||
conn.close()
|
|
||||||
LOG.debug(_("ServerProxy: status=%(status)d, reason=%(reason)r, "
|
LOG.debug(_("ServerProxy: status=%(status)d, reason=%(reason)r, "
|
||||||
"ret=%(ret)s, data=%(data)r"), {'status': ret[0],
|
"ret=%(ret)s, data=%(data)r"), {'status': ret[0],
|
||||||
'reason': ret[1],
|
'reason': ret[1],
|
||||||
@ -187,7 +219,7 @@ class ServerProxy(object):
|
|||||||
|
|
||||||
class ServerPool(object):
|
class ServerPool(object):
|
||||||
|
|
||||||
def __init__(self, timeout=10,
|
def __init__(self, timeout=False,
|
||||||
base_uri=BASE_URI, name='NeutronRestProxy'):
|
base_uri=BASE_URI, name='NeutronRestProxy'):
|
||||||
LOG.debug(_("ServerPool: initializing"))
|
LOG.debug(_("ServerPool: initializing"))
|
||||||
# 'servers' is the list of network controller REST end-points
|
# 'servers' is the list of network controller REST end-points
|
||||||
@ -200,8 +232,9 @@ class ServerPool(object):
|
|||||||
self.base_uri = base_uri
|
self.base_uri = base_uri
|
||||||
self.name = name
|
self.name = name
|
||||||
self.timeout = cfg.CONF.RESTPROXY.server_timeout
|
self.timeout = cfg.CONF.RESTPROXY.server_timeout
|
||||||
|
self.always_reconnect = not cfg.CONF.RESTPROXY.cache_connections
|
||||||
default_port = 8000
|
default_port = 8000
|
||||||
if timeout is not None:
|
if timeout is not False:
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
# Function to use to retrieve topology for consistency syncs.
|
# Function to use to retrieve topology for consistency syncs.
|
||||||
@ -244,8 +277,99 @@ class ServerPool(object):
|
|||||||
return self.capabilities
|
return self.capabilities
|
||||||
|
|
||||||
def server_proxy_for(self, server, port):
|
def server_proxy_for(self, server, port):
|
||||||
|
combined_cert = self._get_combined_cert_for_server(server, port)
|
||||||
return ServerProxy(server, port, self.ssl, self.auth, self.neutron_id,
|
return ServerProxy(server, port, self.ssl, self.auth, self.neutron_id,
|
||||||
self.timeout, self.base_uri, self.name, mypool=self)
|
self.timeout, self.base_uri, self.name, mypool=self,
|
||||||
|
combined_cert=combined_cert)
|
||||||
|
|
||||||
|
def _get_combined_cert_for_server(self, server, port):
|
||||||
|
# The ssl library requires a combined file with all trusted certs
|
||||||
|
# so we make one containing the trusted CAs and the corresponding
|
||||||
|
# host cert for this server
|
||||||
|
combined_cert = None
|
||||||
|
if self.ssl and not cfg.CONF.RESTPROXY.no_ssl_validation:
|
||||||
|
base_ssl = cfg.CONF.RESTPROXY.ssl_cert_directory
|
||||||
|
host_dir = os.path.join(base_ssl, 'host_certs')
|
||||||
|
ca_dir = os.path.join(base_ssl, 'ca_certs')
|
||||||
|
combined_dir = os.path.join(base_ssl, 'combined')
|
||||||
|
combined_cert = os.path.join(combined_dir, '%s.pem' % server)
|
||||||
|
if not os.path.exists(base_ssl):
|
||||||
|
raise cfg.Error(_('ssl_cert_directory [%s] does not exist. '
|
||||||
|
'Create it or disable ssl.') % base_ssl)
|
||||||
|
for automake in [combined_dir, ca_dir, host_dir]:
|
||||||
|
if not os.path.exists(automake):
|
||||||
|
os.makedirs(automake)
|
||||||
|
|
||||||
|
# get all CA certs
|
||||||
|
certs = self._get_ca_cert_paths(ca_dir)
|
||||||
|
|
||||||
|
# check for a host specific cert
|
||||||
|
hcert, exists = self._get_host_cert_path(host_dir, server)
|
||||||
|
if exists:
|
||||||
|
certs.append(hcert)
|
||||||
|
elif cfg.CONF.RESTPROXY.ssl_sticky:
|
||||||
|
self._fetch_and_store_cert(server, port, hcert)
|
||||||
|
certs.append(hcert)
|
||||||
|
if not certs:
|
||||||
|
raise cfg.Error(_('No certificates were found to verify '
|
||||||
|
'controller %s') % (server))
|
||||||
|
self._combine_certs_to_file(certs, combined_cert)
|
||||||
|
return combined_cert
|
||||||
|
|
||||||
|
def _combine_certs_to_file(certs, cfile):
|
||||||
|
'''
|
||||||
|
Concatenates the contents of each certificate in a list of
|
||||||
|
certificate paths to one combined location for use with ssl
|
||||||
|
sockets.
|
||||||
|
'''
|
||||||
|
with open(cfile, 'w') as combined:
|
||||||
|
for c in certs:
|
||||||
|
with open(c, 'r') as cert_handle:
|
||||||
|
combined.write(cert_handle.read())
|
||||||
|
|
||||||
|
def _get_host_cert_path(self, host_dir, server):
|
||||||
|
'''
|
||||||
|
returns full path and boolean indicating existence
|
||||||
|
'''
|
||||||
|
hcert = os.path.join(host_dir, '%s.pem' % server)
|
||||||
|
if os.path.exists(hcert):
|
||||||
|
return hcert, True
|
||||||
|
return hcert, False
|
||||||
|
|
||||||
|
def _get_ca_cert_paths(self, ca_dir):
|
||||||
|
certs = [os.path.join(root, name)
|
||||||
|
for name in [
|
||||||
|
name for (root, dirs, files) in os.walk(ca_dir)
|
||||||
|
for name in files
|
||||||
|
]
|
||||||
|
if name.endswith('.pem')]
|
||||||
|
return certs
|
||||||
|
|
||||||
|
def _fetch_and_store_cert(self, server, port, path):
|
||||||
|
'''
|
||||||
|
Grabs a certificate from a server and writes it to
|
||||||
|
a given path.
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
cert = ssl.get_server_certificate((server, port))
|
||||||
|
except Exception as e:
|
||||||
|
raise cfg.Error(_('Could not retrieve initial '
|
||||||
|
'certificate from controller %(server)s. '
|
||||||
|
'Error details: %(error)s'),
|
||||||
|
{'server': server, 'error': e.strerror})
|
||||||
|
|
||||||
|
LOG.warning(_("Storing to certificate for host %(server)s "
|
||||||
|
"at %(path)s") % {'server': server,
|
||||||
|
'path': path})
|
||||||
|
self._file_put_contents(path, cert)
|
||||||
|
|
||||||
|
return cert
|
||||||
|
|
||||||
|
def _file_put_contents(path, contents):
|
||||||
|
# Simple method to write to file.
|
||||||
|
# Created for easy Mocking
|
||||||
|
with open(path, 'w') as handle:
|
||||||
|
handle.write(contents)
|
||||||
|
|
||||||
def server_failure(self, resp, ignore_codes=[]):
|
def server_failure(self, resp, ignore_codes=[]):
|
||||||
"""Define failure codes as required.
|
"""Define failure codes as required.
|
||||||
@ -264,12 +388,13 @@ class ServerPool(object):
|
|||||||
|
|
||||||
@utils.synchronized('bsn-rest-call')
|
@utils.synchronized('bsn-rest-call')
|
||||||
def rest_call(self, action, resource, data, headers, ignore_codes,
|
def rest_call(self, action, resource, data, headers, ignore_codes,
|
||||||
timeout=None):
|
timeout=False):
|
||||||
good_first = sorted(self.servers, key=lambda x: x.failed)
|
good_first = sorted(self.servers, key=lambda x: x.failed)
|
||||||
first_response = None
|
first_response = None
|
||||||
for active_server in good_first:
|
for active_server in good_first:
|
||||||
ret = active_server.rest_call(action, resource, data, headers,
|
ret = active_server.rest_call(action, resource, data, headers,
|
||||||
timeout)
|
timeout,
|
||||||
|
reconnect=self.always_reconnect)
|
||||||
# If inconsistent, do a full synchronization
|
# If inconsistent, do a full synchronization
|
||||||
if ret[0] == httplib.CONFLICT:
|
if ret[0] == httplib.CONFLICT:
|
||||||
if not self.get_topo_function:
|
if not self.get_topo_function:
|
||||||
@ -309,7 +434,7 @@ class ServerPool(object):
|
|||||||
return first_response
|
return first_response
|
||||||
|
|
||||||
def rest_action(self, action, resource, data='', errstr='%s',
|
def rest_action(self, action, resource, data='', errstr='%s',
|
||||||
ignore_codes=[], headers={}, timeout=None):
|
ignore_codes=[], headers={}, timeout=False):
|
||||||
"""
|
"""
|
||||||
Wrapper for rest_call that verifies success and raises a
|
Wrapper for rest_call that verifies success and raises a
|
||||||
RemoteRestError on failure with a provided error string
|
RemoteRestError on failure with a provided error string
|
||||||
@ -427,3 +552,26 @@ class ServerPool(object):
|
|||||||
# that will be handled by the rest_call.
|
# that will be handled by the rest_call.
|
||||||
time.sleep(polling_interval)
|
time.sleep(polling_interval)
|
||||||
self.servers.rest_call('GET', HEALTH_PATH)
|
self.servers.rest_call('GET', HEALTH_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPSConnectionWithValidation(httplib.HTTPSConnection):
|
||||||
|
|
||||||
|
# If combined_cert is None, the connection will continue without
|
||||||
|
# any certificate validation.
|
||||||
|
combined_cert = None
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
sock = socket.create_connection((self.host, self.port),
|
||||||
|
self.timeout, self.source_address)
|
||||||
|
if self._tunnel_host:
|
||||||
|
self.sock = sock
|
||||||
|
self._tunnel()
|
||||||
|
|
||||||
|
if self.combined_cert:
|
||||||
|
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
|
||||||
|
cert_reqs=ssl.CERT_REQUIRED,
|
||||||
|
ca_certs=self.combined_cert)
|
||||||
|
else:
|
||||||
|
self.sock = ssl.wrap_socket(sock, self.key_file,
|
||||||
|
self.cert_file,
|
||||||
|
cert_reqs=ssl.CERT_NONE)
|
||||||
|
2
neutron/tests/unit/bigswitch/etc/ssl/ca_certs/README
Normal file
2
neutron/tests/unit/bigswitch/etc/ssl/ca_certs/README
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ca_certs directory for SSL unit tests
|
||||||
|
No files will be generated here, but it should exist for the tests
|
2
neutron/tests/unit/bigswitch/etc/ssl/combined/README
Normal file
2
neutron/tests/unit/bigswitch/etc/ssl/combined/README
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
combined certificates directory for SSL unit tests
|
||||||
|
No files will be created here, but it should exist for the tests
|
2
neutron/tests/unit/bigswitch/etc/ssl/host_certs/README
Normal file
2
neutron/tests/unit/bigswitch/etc/ssl/host_certs/README
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
host_certs directory for SSL unit tests
|
||||||
|
No files will be created here, but it should exist for the tests
|
@ -139,3 +139,48 @@ class VerifyMultiTenantFloatingIP(HTTPConnectionMock):
|
|||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
super(VerifyMultiTenantFloatingIP,
|
super(VerifyMultiTenantFloatingIP,
|
||||||
self).request(action, uri, body, headers)
|
self).request(action, uri, body, headers)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPSMockBase(HTTPConnectionMock):
|
||||||
|
expected_cert = ''
|
||||||
|
combined_cert = None
|
||||||
|
|
||||||
|
def __init__(self, host, port=None, key_file=None, cert_file=None,
|
||||||
|
strict=None, timeout=None, source_address=None):
|
||||||
|
self.host = host
|
||||||
|
super(HTTPSMockBase, self).__init__(host, port, timeout)
|
||||||
|
|
||||||
|
def request(self, method, url, body=None, headers={}):
|
||||||
|
self.connect()
|
||||||
|
super(HTTPSMockBase, self).request(method, url, body, headers)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPSNoValidation(HTTPSMockBase):
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
if self.combined_cert:
|
||||||
|
raise Exception('combined_cert set on NoValidation')
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPSCAValidation(HTTPSMockBase):
|
||||||
|
expected_cert = 'DUMMYCERTIFICATEAUTHORITY'
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
contents = get_cert_contents(self.combined_cert)
|
||||||
|
if self.expected_cert not in contents:
|
||||||
|
raise Exception('No dummy CA cert in cert_file')
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPSHostValidation(HTTPSMockBase):
|
||||||
|
expected_cert = 'DUMMYCERTFORHOST%s'
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
contents = get_cert_contents(self.combined_cert)
|
||||||
|
expected = self.expected_cert % self.host
|
||||||
|
if expected not in contents:
|
||||||
|
raise Exception(_('No host cert for %(server)s in cert %(cert)s'),
|
||||||
|
{'server': self.host, 'cert': contents})
|
||||||
|
|
||||||
|
|
||||||
|
def get_cert_contents(path):
|
||||||
|
raise Exception('METHOD MUST BE MOCKED FOR TEST')
|
||||||
|
@ -45,6 +45,12 @@ class BigSwitchTestBase(object):
|
|||||||
'restproxy.ini.test')]
|
'restproxy.ini.test')]
|
||||||
self.addCleanup(cfg.CONF.reset)
|
self.addCleanup(cfg.CONF.reset)
|
||||||
config.register_config()
|
config.register_config()
|
||||||
|
# Only try SSL on SSL tests
|
||||||
|
cfg.CONF.set_override('server_ssl', False, 'RESTPROXY')
|
||||||
|
cfg.CONF.set_override('ssl_cert_directory',
|
||||||
|
os.path.join(etc_path, 'ssl'), 'RESTPROXY')
|
||||||
|
# The mock interferes with HTTP(S) connection caching
|
||||||
|
cfg.CONF.set_override('cache_connections', False, 'RESTPROXY')
|
||||||
|
|
||||||
def setup_patches(self):
|
def setup_patches(self):
|
||||||
self.httpPatch = mock.patch(HTTPCON, create=True,
|
self.httpPatch = mock.patch(HTTPCON, create=True,
|
||||||
|
251
neutron/tests/unit/bigswitch/test_ssl.py
Normal file
251
neutron/tests/unit/bigswitch/test_ssl.py
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# Copyright 2014 Big Switch Networks, Inc. All rights reserved.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# @author: Kevin Benton, kevin.benton@bigswitch.com
|
||||||
|
#
|
||||||
|
import os
|
||||||
|
|
||||||
|
import mock
|
||||||
|
from oslo.config import cfg
|
||||||
|
import webob.exc
|
||||||
|
|
||||||
|
from neutron.openstack.common import log as logging
|
||||||
|
from neutron.tests.unit.bigswitch import fake_server
|
||||||
|
from neutron.tests.unit.bigswitch import test_base
|
||||||
|
from neutron.tests.unit import test_api_v2
|
||||||
|
from neutron.tests.unit import test_db_plugin as test_plugin
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SERVERMANAGER = 'neutron.plugins.bigswitch.servermanager'
|
||||||
|
HTTPS = SERVERMANAGER + '.HTTPSConnectionWithValidation'
|
||||||
|
CERTCOMBINER = SERVERMANAGER + '.ServerPool._combine_certs_to_file'
|
||||||
|
FILEPUT = SERVERMANAGER + '.ServerPool._file_put_contents'
|
||||||
|
GETCACERTS = SERVERMANAGER + '.ServerPool._get_ca_cert_paths'
|
||||||
|
GETHOSTCERT = SERVERMANAGER + '.ServerPool._get_host_cert_path'
|
||||||
|
FAKECERTGET = 'neutron.tests.unit.bigswitch.fake_server.get_cert_contents'
|
||||||
|
SSLGETCERT = 'ssl.get_server_certificate'
|
||||||
|
|
||||||
|
|
||||||
|
class test_ssl_certificate_base(test_plugin.NeutronDbPluginV2TestCase,
|
||||||
|
test_base.BigSwitchTestBase):
|
||||||
|
|
||||||
|
plugin_str = ('%s.NeutronRestProxyV2' %
|
||||||
|
test_base.RESTPROXY_PKG_PATH)
|
||||||
|
servername = None
|
||||||
|
cert_base = None
|
||||||
|
|
||||||
|
def _setUp(self):
|
||||||
|
self.servername = test_api_v2._uuid()
|
||||||
|
self.cert_base = cfg.CONF.RESTPROXY.ssl_cert_directory
|
||||||
|
self.host_cert_val = 'DUMMYCERTFORHOST%s' % self.servername
|
||||||
|
self.host_cert_path = os.path.join(
|
||||||
|
self.cert_base,
|
||||||
|
'host_certs',
|
||||||
|
'%s.pem' % self.servername
|
||||||
|
)
|
||||||
|
self.comb_cert_path = os.path.join(
|
||||||
|
self.cert_base,
|
||||||
|
'combined',
|
||||||
|
'%s.pem' % self.servername
|
||||||
|
)
|
||||||
|
self.ca_certs_path = os.path.join(
|
||||||
|
self.cert_base,
|
||||||
|
'ca_certs'
|
||||||
|
)
|
||||||
|
cfg.CONF.set_override('servers', ["%s:443" % self.servername],
|
||||||
|
'RESTPROXY')
|
||||||
|
self.setup_patches()
|
||||||
|
|
||||||
|
# Mock method SSL lib uses to grab cert from server
|
||||||
|
self.sslgetcert_m = mock.patch(SSLGETCERT, create=True).start()
|
||||||
|
self.sslgetcert_m.return_value = self.host_cert_val
|
||||||
|
|
||||||
|
# Mock methods that write and read certs from the file-system
|
||||||
|
self.fileput_m = mock.patch(FILEPUT, create=True).start()
|
||||||
|
self.certcomb_m = mock.patch(CERTCOMBINER, create=True).start()
|
||||||
|
self.getcacerts_m = mock.patch(GETCACERTS, create=True).start()
|
||||||
|
|
||||||
|
# this is used to configure what certificate contents the fake HTTPS
|
||||||
|
# lib should expect to receive
|
||||||
|
self.fake_certget_m = mock.patch(FAKECERTGET, create=True).start()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(test_ssl_certificate_base, self).setUp(self.plugin_str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSslSticky(test_ssl_certificate_base):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_config_files()
|
||||||
|
cfg.CONF.set_override('server_ssl', True, 'RESTPROXY')
|
||||||
|
cfg.CONF.set_override('ssl_sticky', True, 'RESTPROXY')
|
||||||
|
self.httpsPatch = mock.patch(HTTPS, create=True,
|
||||||
|
new=fake_server.HTTPSHostValidation)
|
||||||
|
self.httpsPatch.start()
|
||||||
|
self._setUp()
|
||||||
|
# Set fake HTTPS connection's expectation
|
||||||
|
self.fake_certget_m.return_value = self.host_cert_val
|
||||||
|
# No CA certs for this test
|
||||||
|
self.getcacerts_m.return_value = []
|
||||||
|
super(TestSslSticky, self).setUp()
|
||||||
|
|
||||||
|
def test_sticky_cert(self):
|
||||||
|
# SSL connection should be successful and cert should be cached
|
||||||
|
with self.network():
|
||||||
|
# CA certs should have been checked for
|
||||||
|
self.getcacerts_m.assert_has_calls([mock.call(self.ca_certs_path)])
|
||||||
|
# cert should have been fetched via SSL lib
|
||||||
|
self.sslgetcert_m.assert_has_calls(
|
||||||
|
[mock.call((self.servername, 443))]
|
||||||
|
)
|
||||||
|
|
||||||
|
# cert should have been recorded
|
||||||
|
self.fileput_m.assert_has_calls([mock.call(self.host_cert_path,
|
||||||
|
self.host_cert_val)])
|
||||||
|
# no ca certs, so host cert only for this combined cert
|
||||||
|
self.certcomb_m.assert_has_calls([mock.call([self.host_cert_path],
|
||||||
|
self.comb_cert_path)])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSslHostCert(test_ssl_certificate_base):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_config_files()
|
||||||
|
cfg.CONF.set_override('server_ssl', True, 'RESTPROXY')
|
||||||
|
cfg.CONF.set_override('ssl_sticky', False, 'RESTPROXY')
|
||||||
|
self.httpsPatch = mock.patch(HTTPS, create=True,
|
||||||
|
new=fake_server.HTTPSHostValidation)
|
||||||
|
self.httpsPatch.start()
|
||||||
|
self._setUp()
|
||||||
|
# Set fake HTTPS connection's expectation
|
||||||
|
self.fake_certget_m.return_value = self.host_cert_val
|
||||||
|
# No CA certs for this test
|
||||||
|
self.getcacerts_m.return_value = []
|
||||||
|
# Pretend host cert exists
|
||||||
|
self.hcertpath_p = mock.patch(GETHOSTCERT,
|
||||||
|
return_value=(self.host_cert_path, True),
|
||||||
|
create=True).start()
|
||||||
|
super(TestSslHostCert, self).setUp()
|
||||||
|
|
||||||
|
def test_host_cert(self):
|
||||||
|
# SSL connection should be successful because of pre-configured cert
|
||||||
|
with self.network():
|
||||||
|
self.hcertpath_p.assert_has_calls([
|
||||||
|
mock.call(os.path.join(self.cert_base, 'host_certs'),
|
||||||
|
self.servername)
|
||||||
|
])
|
||||||
|
# sticky is disabled, no fetching allowed
|
||||||
|
self.assertFalse(self.sslgetcert_m.call_count)
|
||||||
|
# no ca certs, so host cert is only for this combined cert
|
||||||
|
self.certcomb_m.assert_has_calls([mock.call([self.host_cert_path],
|
||||||
|
self.comb_cert_path)])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSslCaCert(test_ssl_certificate_base):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_config_files()
|
||||||
|
cfg.CONF.set_override('server_ssl', True, 'RESTPROXY')
|
||||||
|
cfg.CONF.set_override('ssl_sticky', False, 'RESTPROXY')
|
||||||
|
self.httpsPatch = mock.patch(HTTPS, create=True,
|
||||||
|
new=fake_server.HTTPSCAValidation)
|
||||||
|
self.httpsPatch.start()
|
||||||
|
self._setUp()
|
||||||
|
|
||||||
|
# pretend to have a few ca certs
|
||||||
|
self.getcacerts_m.return_value = ['ca1.pem', 'ca2.pem']
|
||||||
|
|
||||||
|
# Set fake HTTPS connection's expectation
|
||||||
|
self.fake_certget_m.return_value = 'DUMMYCERTIFICATEAUTHORITY'
|
||||||
|
|
||||||
|
super(TestSslCaCert, self).setUp()
|
||||||
|
|
||||||
|
def test_ca_cert(self):
|
||||||
|
# SSL connection should be successful because CA cert was present
|
||||||
|
# If not, attempting to create a network would raise an exception
|
||||||
|
with self.network():
|
||||||
|
# sticky is disabled, no fetching allowed
|
||||||
|
self.assertFalse(self.sslgetcert_m.call_count)
|
||||||
|
# 2 CAs and no host cert so combined should only contain both CAs
|
||||||
|
self.certcomb_m.assert_has_calls([mock.call(['ca1.pem', 'ca2.pem'],
|
||||||
|
self.comb_cert_path)])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSslWrongHostCert(test_ssl_certificate_base):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_config_files()
|
||||||
|
cfg.CONF.set_override('server_ssl', True, 'RESTPROXY')
|
||||||
|
cfg.CONF.set_override('ssl_sticky', True, 'RESTPROXY')
|
||||||
|
self.httpsPatch = mock.patch(HTTPS, create=True,
|
||||||
|
new=fake_server.HTTPSHostValidation)
|
||||||
|
self.httpsPatch.start()
|
||||||
|
self._setUp()
|
||||||
|
|
||||||
|
# Set fake HTTPS connection's expectation to something wrong
|
||||||
|
self.fake_certget_m.return_value = 'OTHERCERT'
|
||||||
|
|
||||||
|
# No CA certs for this test
|
||||||
|
self.getcacerts_m.return_value = []
|
||||||
|
|
||||||
|
# Pretend host cert exists
|
||||||
|
self.hcertpath_p = mock.patch(GETHOSTCERT,
|
||||||
|
return_value=(self.host_cert_path, True),
|
||||||
|
create=True).start()
|
||||||
|
super(TestSslWrongHostCert, self).setUp()
|
||||||
|
|
||||||
|
def test_error_no_cert(self):
|
||||||
|
# since there will already be a host cert, sticky should not take
|
||||||
|
# effect and there will be an error because the host cert's contents
|
||||||
|
# will be incorrect
|
||||||
|
tid = test_api_v2._uuid()
|
||||||
|
data = {}
|
||||||
|
data['network'] = {'tenant_id': tid, 'name': 'name',
|
||||||
|
'admin_state_up': True}
|
||||||
|
req = self.new_create_request('networks', data, 'json')
|
||||||
|
res = req.get_response(self.api)
|
||||||
|
self.assertEqual(res.status_int,
|
||||||
|
webob.exc.HTTPInternalServerError.code)
|
||||||
|
self.hcertpath_p.assert_has_calls([
|
||||||
|
mock.call(os.path.join(self.cert_base, 'host_certs'),
|
||||||
|
self.servername)
|
||||||
|
])
|
||||||
|
# sticky is enabled, but a host cert already exists so it shant fetch
|
||||||
|
self.assertFalse(self.sslgetcert_m.call_count)
|
||||||
|
# no ca certs, so host cert only for this combined cert
|
||||||
|
self.certcomb_m.assert_has_calls([mock.call([self.host_cert_path],
|
||||||
|
self.comb_cert_path)])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSslNoValidation(test_ssl_certificate_base):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_config_files()
|
||||||
|
cfg.CONF.set_override('server_ssl', True, 'RESTPROXY')
|
||||||
|
cfg.CONF.set_override('ssl_sticky', False, 'RESTPROXY')
|
||||||
|
cfg.CONF.set_override('no_ssl_validation', True, 'RESTPROXY')
|
||||||
|
self.httpsPatch = mock.patch(HTTPS, create=True,
|
||||||
|
new=fake_server.HTTPSNoValidation)
|
||||||
|
self.httpsPatch.start()
|
||||||
|
self._setUp()
|
||||||
|
super(TestSslNoValidation, self).setUp()
|
||||||
|
|
||||||
|
def test_validation_disabled(self):
|
||||||
|
# SSL connection should be successful without any certificates
|
||||||
|
# If not, attempting to create a network will raise an exception
|
||||||
|
with self.network():
|
||||||
|
# no sticky grabbing and no cert combining with no enforcement
|
||||||
|
self.assertFalse(self.sslgetcert_m.call_count)
|
||||||
|
self.assertFalse(self.certcomb_m.call_count)
|
@ -47,7 +47,10 @@ data_files =
|
|||||||
etc/neutron/rootwrap.d/ryu-plugin.filters
|
etc/neutron/rootwrap.d/ryu-plugin.filters
|
||||||
etc/neutron/rootwrap.d/vpnaas.filters
|
etc/neutron/rootwrap.d/vpnaas.filters
|
||||||
etc/init.d = etc/init.d/neutron-server
|
etc/init.d = etc/init.d/neutron-server
|
||||||
etc/neutron/plugins/bigswitch = etc/neutron/plugins/bigswitch/restproxy.ini
|
etc/neutron/plugins/bigswitch =
|
||||||
|
etc/neutron/plugins/bigswitch/restproxy.ini
|
||||||
|
etc/neutron/plugins/bigswitch/ssl/ca_certs/README
|
||||||
|
etc/neutron/plugins/bigswitch/ssl/host_certs/README
|
||||||
etc/neutron/plugins/brocade = etc/neutron/plugins/brocade/brocade.ini
|
etc/neutron/plugins/brocade = etc/neutron/plugins/brocade/brocade.ini
|
||||||
etc/neutron/plugins/cisco = etc/neutron/plugins/cisco/cisco_plugins.ini
|
etc/neutron/plugins/cisco = etc/neutron/plugins/cisco/cisco_plugins.ini
|
||||||
etc/neutron/plugins/hyperv = etc/neutron/plugins/hyperv/hyperv_neutron_plugin.ini
|
etc/neutron/plugins/hyperv = etc/neutron/plugins/hyperv/hyperv_neutron_plugin.ini
|
||||||
|
Loading…
Reference in New Issue
Block a user