diff --git a/example.conf b/example.conf index f57fdbe19..f8c116376 100644 --- a/example.conf +++ b/example.conf @@ -43,6 +43,15 @@ # Deprecated group/name - [discoverd]/clean_up_period #clean_up_period = 60 +# SSL Enabled/Disabled (boolean value) +#use_ssl = false + +# Path to SSL certificate (string value) +#ssl_cert_path = + +# Path to SSL key (string value) +#ssl_key_path = + [firewall] diff --git a/ironic_inspector/conf.py b/ironic_inspector/conf.py index f30141399..ea1cc652e 100644 --- a/ironic_inspector/conf.py +++ b/ironic_inspector/conf.py @@ -169,6 +169,15 @@ SERVICE_OPTS = [ help='Amount of time in seconds, after which repeat clean up ' 'of timed out nodes and old nodes status information.', deprecated_group='discoverd'), + cfg.BoolOpt('use_ssl', + default=False, + help='SSL Enabled/Disabled'), + cfg.StrOpt('ssl_cert_path', + default='', + help='Path to SSL certificate'), + cfg.StrOpt('ssl_key_path', + default='', + help='Path to SSL key'), ] diff --git a/ironic_inspector/main.py b/ironic_inspector/main.py index 4f47493d5..3342a1134 100644 --- a/ironic_inspector/main.py +++ b/ironic_inspector/main.py @@ -17,6 +17,7 @@ eventlet.monkey_patch() import functools import json import logging +import ssl import sys import flask @@ -168,6 +169,37 @@ def init(): LOG.warning(_LW('Timeout is disabled in configuration')) +def create_ssl_context(): + if not CONF.use_ssl: + return + + MIN_VERSION = (2, 7, 9) + + if sys.version_info < MIN_VERSION: + LOG.warning(_LW('Unable to use SSL in this version of Python: ' + '%{current}, please ensure your version of Python is ' + 'greater than %{min} to enable this feature.'), + {'current': '.'.join(map(str, sys.version_info[:3])), + 'min': '.'.join(map(str, MIN_VERSION))}) + return + + context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + if CONF.ssl_cert_path and CONF.ssl_key_path: + try: + context.load_cert_chain(CONF.ssl_cert_path, CONF.ssl_key_path) + except IOError as exc: + LOG.warning(_LW('Failed to load certificate or key from defined ' + 'locations: %{cert} and %{key}, will continue to ' + 'run with the default settings: %{exc}'), + {'cert': CONF.ssl_cert_path, 'key': CONF.ssl_key_path, + 'exc': exc}) + except ssl.SSLError as exc: + LOG.warning(_LW('There was a problem with the loaded certificate ' + 'and key, will continue to run with the default ' + 'settings: %s'), exc) + return context + + def main(args=sys.argv[1:]): # pragma: no cover CONF(args, project='ironic-inspector') debug = CONF.debug @@ -180,10 +212,16 @@ def main(args=sys.argv[1:]): # pragma: no cover logging.getLogger('ironicclient.common.http').setLevel( logging.INFO if debug else logging.ERROR) + app_kwargs = {'debug': debug, + 'host': CONF.listen_address, + 'port': CONF.listen_port} + + context = create_ssl_context() + if context: + app_kwargs['ssl_context'] = context + init() try: - app.run(debug=debug, - host=CONF.listen_address, - port=CONF.listen_port) + app.run(**app_kwargs) finally: firewall.clean_up() diff --git a/ironic_inspector/test/test_main.py b/ironic_inspector/test/test_main.py index f4b2a2c72..46fad2799 100644 --- a/ironic_inspector/test/test_main.py +++ b/ironic_inspector/test/test_main.py @@ -12,6 +12,8 @@ # limitations under the License. import json +import ssl +import sys import unittest import eventlet @@ -242,3 +244,86 @@ class TestInit(test_base.BaseTest): self.assertRaises(SystemExit, main.init) mock_log.assert_called_once_with(mock.ANY, "'foo!'") + + +class TestCreateSSLContext(test_base.BaseTest): + + def test_use_ssl_false(self): + CONF.set_override('use_ssl', False) + con = main.create_ssl_context() + self.assertIsNone(con) + + @mock.patch.object(sys, 'version_info') + def test_old_python_returns_none(self, mock_version_info): + mock_version_info.__lt__.return_value = True + CONF.set_override('use_ssl', True) + con = main.create_ssl_context() + self.assertIsNone(con) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_use_ssl_true(self, mock_cdc): + CONF.set_override('use_ssl', True) + m_con = mock_cdc() + con = main.create_ssl_context() + self.assertEqual(m_con, con) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_only_key_path_provided(self, mock_cdc): + CONF.set_override('use_ssl', True) + CONF.set_override('ssl_key_path', '/some/fake/path') + mock_context = mock_cdc() + con = main.create_ssl_context() + self.assertEqual(mock_context, con) + self.assertFalse(mock_context.load_cert_chain.called) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_only_cert_path_provided(self, mock_cdc): + CONF.set_override('use_ssl', True) + CONF.set_override('ssl_cert_path', '/some/fake/path') + mock_context = mock_cdc() + con = main.create_ssl_context() + self.assertEqual(mock_context, con) + self.assertFalse(mock_context.load_cert_chain.called) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_both_paths_provided(self, mock_cdc): + key_path = '/some/fake/path/key' + cert_path = '/some/fake/path/cert' + CONF.set_override('use_ssl', True) + CONF.set_override('ssl_key_path', key_path) + CONF.set_override('ssl_cert_path', cert_path) + mock_context = mock_cdc() + con = main.create_ssl_context() + self.assertEqual(mock_context, con) + mock_context.load_cert_chain.assert_called_once_with(cert_path, + key_path) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_load_cert_chain_fails(self, mock_cdc): + CONF.set_override('use_ssl', True) + key_path = '/some/fake/path/key' + cert_path = '/some/fake/path/cert' + CONF.set_override('use_ssl', True) + CONF.set_override('ssl_key_path', key_path) + CONF.set_override('ssl_cert_path', cert_path) + mock_context = mock_cdc() + mock_context.load_cert_chain.side_effect = IOError('Boom!') + con = main.create_ssl_context() + self.assertEqual(mock_context, con) + mock_context.load_cert_chain.assert_called_once_with(cert_path, + key_path)