diff --git a/ironic/common/wsgi_service.py b/ironic/common/wsgi_service.py index 8b487c2ad3..6e966658be 100644 --- a/ironic/common/wsgi_service.py +++ b/ironic/common/wsgi_service.py @@ -10,11 +10,15 @@ # License for the specific language governing permissions and limitations # under the License. -import socket +import os +import threading +from cheroot.ssl import builtin as cheroot_ssl +from cheroot import wsgi from oslo_concurrency import processutils +from oslo_log import log as logging from oslo_service import service -from oslo_service import wsgi +from oslo_service import sslutils from ironic.api import app from ironic.common import exception @@ -23,9 +27,22 @@ from ironic.common import utils from ironic.conf import CONF +LOG = logging.getLogger(__name__) _MAX_DEFAULT_WORKERS = 4 +def validate_cert_paths(cert_file, key_file): + if cert_file and not os.path.exists(cert_file): + raise RuntimeError(_("Unable to find cert_file: %s") % cert_file) + if key_file and not os.path.exists(key_file): + raise RuntimeError(_("Unable to find key_file: %s") % key_file) + + if not cert_file or not key_file: + raise RuntimeError(_("When running server in SSL mode, you must " + "specify a valid cert_file and key_file " + "paths in your configuration file")) + + class BaseWSGIService(service.ServiceBase): def __init__(self, name, app, conf, use_ssl=None): @@ -41,48 +58,89 @@ class BaseWSGIService(service.ServiceBase): self._conf = conf if use_ssl is None: use_ssl = conf.use_ssl + + socket_mode = None + bind_addr = (conf.host_ip, conf.port) if conf.unix_socket: utils.unlink_without_raise(conf.unix_socket) - self.server = wsgi.Server(CONF, name, app, - socket_family=socket.AF_UNIX, - socket_file=conf.unix_socket, - socket_mode=conf.unix_socket_mode, - use_ssl=use_ssl) - else: - self.server = wsgi.Server(CONF, name, app, - host=conf.host_ip, - port=conf.port, - use_ssl=use_ssl) + bind_addr = conf.unix_socket + socket_mode = conf.unix_socket_mode + + self.server = wsgi.Server( + bind_addr=bind_addr, + wsgi_app=app, + server_name=name) + + if use_ssl: + cert_file = getattr(conf, "cert_file", None) + key_file = getattr(conf, "key_file", None) + + if not (cert_file and key_file): + LOG.warning( + "Falling back to deprecated [ssl] group for TLS " + "credentials: the global [ssl] configuration block is " + "deprecated and will be removed in 2026.1" + ) + + # Register global SSL config options and validate the + # existence of configured certificate/private key file paths, + # when in secure mode. + sslutils.is_enabled(CONF) + cert_file = CONF.ssl.cert_file + key_file = CONF.ssl.key_file + + validate_cert_paths(cert_file, key_file) + + self.server.ssl_adapter = cheroot_ssl.BuiltinSSLAdapter( + certificate=cert_file, + private_key=key_file, + ) + + self._unix_socket = conf.unix_socket + self._socket_mode = socket_mode + self._thread = None def start(self): """Start serving this service using loaded configuration. :returns: None """ - self.server.start() + self.server.prepare() + + if self._unix_socket and self._socket_mode is not None: + os.chmod(self._unix_socket, self._socket_mode) + + self._thread = threading.Thread( + target=self.server.serve, + daemon=True + ) + + self._thread.start() def stop(self): """Stop serving this API. :returns: None """ - self.server.stop() - if self._conf.unix_socket: - utils.unlink_without_raise(self._conf.unix_socket) + if self.server: + self.server.stop() + if self._thread: + self._thread.join(timeout=2) + + if self._unix_socket: + utils.unlink_without_raise(self._unix_socket) def wait(self): """Wait for the service to stop serving this API. :returns: None """ - self.server.wait() + if self._thread: + self._thread.join() def reset(self): - """Reset server greenpool size to default. - - :returns: None - """ - self.server.reset() + """No server greenpools to resize.""" + pass class WSGIService(BaseWSGIService): diff --git a/ironic/conf/api.py b/ironic/conf/api.py index b04048d1d8..a096d2084c 100644 --- a/ironic/conf/api.py +++ b/ironic/conf/api.py @@ -103,6 +103,12 @@ opts = [ mutable=True, help=_("Specifies a list of boot modes that are not allowed " "during enrollment. Eg: ['bios']")), + cfg.StrOpt('cert_file', + help="Certificate file to use when starting " + "the server securely."), + cfg.StrOpt('key_file', + help="Private key file to use when starting " + "the server securely."), ] opt_group = cfg.OptGroup(name='api', diff --git a/ironic/conf/json_rpc.py b/ironic/conf/json_rpc.py index 81b39adba9..b95947c639 100644 --- a/ironic/conf/json_rpc.py +++ b/ironic/conf/json_rpc.py @@ -43,9 +43,14 @@ opts = [ cfg.BoolOpt('use_ssl', default=False, help=_('Whether to use TLS for JSON RPC')), + cfg.StrOpt('cert_file', + help=_("Certificate file the JSON-RPC listener will present " + "to clients when [json_rpc]use_ssl=True.")), + cfg.StrOpt('key_file', + help=_("Private key file matching cert_file.")), cfg.BoolOpt('client_use_ssl', default=False, - help=_('Set to True for force TLS connections in the client ' + help=_('Set to True to force TLS connections in the client ' 'even if use_ssl is set to False. Only makes sense ' 'if server-side TLS is provided outside of Ironic ' '(e.g. with httpd acting as a reverse proxy).')), diff --git a/ironic/tests/unit/common/test_json_rpc.py b/ironic/tests/unit/common/test_json_rpc.py index 140e44388b..cfb8dba7d2 100644 --- a/ironic/tests/unit/common/test_json_rpc.py +++ b/ironic/tests/unit/common/test_json_rpc.py @@ -84,7 +84,10 @@ class TestService(TestCase): super(TestService, self).setUp() self.config(auth_strategy='noauth', group='json_rpc') self.server_mock = self.useFixture(fixtures.MockPatch( - 'oslo_service.wsgi.Server', autospec=True)).mock + 'cheroot.wsgi.Server', autospec=True)).mock + + server_instance = self.server_mock.return_value + server_instance.requests = mock.MagicMock() self.serializer = FakeSerializer() self.service = server.WSGIService(FakeManager(), self.serializer, @@ -140,7 +143,7 @@ class TestService(TestCase): # self.config(http_basic_password='myPassword', group='json_rpc') self.service = server.WSGIService(FakeManager(), self.serializer, FakeContext) - self.app = self.server_mock.call_args[0][2] + self.app = self.server_mock.call_args.kwargs['wsgi_app'] def test_http_basic_not_authenticated(self): self._setup_http_basic() @@ -289,7 +292,7 @@ class TestService(TestCase): self.config(auth_strategy='keystone', group='json_rpc') self.service = server.WSGIService(FakeManager(), self.serializer, FakeContext) - self.app = self.server_mock.call_args[0][2] + self.app = self.server_mock.call_args.kwargs['wsgi_app'] self._request('success', {'context': self.ctx, 'x': 42}, expected_error=401) @@ -298,7 +301,7 @@ class TestService(TestCase): self.config(allowed_roles=['allowed', 'ignored'], group='json_rpc') self.service = server.WSGIService(FakeManager(), self.serializer, FakeContext) - self.app = self.server_mock.call_args[0][2] + self.app = self.server_mock.call_args.kwargs['wsgi_app'] self._request('success', {'context': self.ctx, 'x': 42}, expected_error=401, headers={'Content-Type': 'application/json', diff --git a/ironic/tests/unit/common/test_wsgi_service.py b/ironic/tests/unit/common/test_wsgi_service.py index bc63c0dd26..1bafe24b77 100644 --- a/ironic/tests/unit/common/test_wsgi_service.py +++ b/ironic/tests/unit/common/test_wsgi_service.py @@ -14,6 +14,7 @@ from unittest import mock from oslo_concurrency import processutils from oslo_config import cfg +from oslo_service import sslutils from ironic.common import exception from ironic.common import wsgi_service @@ -23,21 +24,28 @@ CONF = cfg.CONF class TestWSGIService(base.TestCase): + def setUp(self): + super().setUp() + + sslutils.register_opts(CONF) + self.server = mock.Mock() + self.server.requests = mock.Mock(min=0, max=0) + @mock.patch.object(processutils, 'get_worker_count', lambda: 2) @mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True) def test_workers_set_default(self, mock_server): service_name = "ironic_api" + mock_server.return_value = self.server test_service = wsgi_service.WSGIService(service_name) self.assertEqual(2, test_service.workers) - mock_server.assert_called_once_with(CONF, service_name, - test_service.app, - host='0.0.0.0', - port=6385, - use_ssl=False) + mock_server.assert_called_once_with(server_name=service_name, + wsgi_app=test_service.app, + bind_addr=('0.0.0.0', 6385)) @mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True) def test_workers_set_correct_setting(self, mock_server): self.config(api_workers=8, group='api') + mock_server.return_value = self.server test_service = wsgi_service.WSGIService("ironic_api") self.assertEqual(8, test_service.workers) @@ -45,6 +53,7 @@ class TestWSGIService(base.TestCase): @mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True) def test_workers_set_zero_setting(self, mock_server): self.config(api_workers=0, group='api') + mock_server.return_value = self.server test_service = wsgi_service.WSGIService("ironic_api") self.assertEqual(3, test_service.workers) @@ -52,24 +61,44 @@ class TestWSGIService(base.TestCase): @mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True) def test_workers_set_default_limit(self, mock_server): self.config(api_workers=0, group='api') + mock_server.return_value = self.server test_service = wsgi_service.WSGIService("ironic_api") self.assertEqual(4, test_service.workers) @mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True) def test_workers_set_negative_setting(self, mock_server): self.config(api_workers=-2, group='api') + mock_server.return_value = self.server self.assertRaises(exception.ConfigInvalid, wsgi_service.WSGIService, 'ironic_api') self.assertFalse(mock_server.called) @mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True) - def test_wsgi_service_with_ssl_enabled(self, mock_server): + @mock.patch('ironic.common.wsgi_service.cheroot_ssl.BuiltinSSLAdapter', + autospec=True) + @mock.patch('ironic.common.wsgi_service.validate_cert_paths', + autospec=True) + @mock.patch('oslo_service.sslutils.is_enabled', return_value=True, + autospec=True) + def test_wsgi_service_with_ssl_enabled(self, mock_is_enabled, + mock_validate_tls, + mock_ssl_adapter, + mock_server): self.config(enable_ssl_api=True, group='api') + self.config(cert_file='/path/to/cert', group='ssl') + self.config(key_file='/path/to/key', group='ssl') + + mock_server.return_value = self.server + service_name = 'ironic_api' srv = wsgi_service.WSGIService('ironic_api', CONF.api.enable_ssl_api) - mock_server.assert_called_once_with(CONF, service_name, - srv.app, - host='0.0.0.0', - port=6385, - use_ssl=True) + mock_server.assert_called_once_with(server_name=service_name, + wsgi_app=srv.app, + bind_addr=('0.0.0.0', 6385)) + + mock_ssl_adapter.assert_called_once_with( + certificate='/path/to/cert', + private_key='/path/to/key' + ) + self.assertIsNotNone(self.server.ssl_adapter) diff --git a/releasenotes/notes/migrate-api-and-json-rpc-out-of-eventlet-4ef744d7601111d6.yaml b/releasenotes/notes/migrate-api-and-json-rpc-out-of-eventlet-4ef744d7601111d6.yaml new file mode 100644 index 0000000000..83e50dffb8 --- /dev/null +++ b/releasenotes/notes/migrate-api-and-json-rpc-out-of-eventlet-4ef744d7601111d6.yaml @@ -0,0 +1,21 @@ +--- +fixes: + - | + The Ironic REST API and JSON-RPC endpoints are now served by + ``cheroot.wsgi.Server`` instead of the deprecated ``oslo_service.wsgi`` + / eventlet stack. Behaviour and CLI commands are unchanged. + +features: + - | + The REST API and JSON-RPC listeners now honour new options in their own + config sections: + + * ``[api]cert_file`` / ``[api]key_file`` + * ``[json_rpc]cert_file`` / ``[json_rpc]key_file`` + + This lets operators present different certificates for each endpoint + without touching the global ``[ssl]`` block as that is now deprecated, + to be removed in **2026.1**. + + Deployments that still rely on the global ``[ssl]`` section are advised + to move the certificate settings to the per-service options. diff --git a/requirements.txt b/requirements.txt index ecfb0cbe4c..c1a6b7ca08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -49,3 +49,4 @@ os-service-types>=1.7.0 # Apache-2.0 bcrypt>=3.1.3 # Apache-2.0 websockify>=0.9.0 # LGPLv3 PyYAML>=6.0.2 # MIT +cheroot>=10.0.1 # BSD