From 702ba777150b87bf84e448d137b79354e31e8285 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 30 Mar 2016 14:23:31 -0400 Subject: [PATCH] Adding the KmipServer This change adds the KmipServer, the front-end of the KMIP software server. The KmipServer is in charge of loading configuration settings, creating all major server components, and serving and managing client connections. A KmipServerConfig tool is included to handle configuration settings. Test cases for all new code are included. --- kmip/core/exceptions.py | 16 + kmip/services/server/__init__.py | 7 + kmip/services/server/config.py | 226 +++++++++ kmip/services/server/server.py | 343 +++++++++++++ .../tests/unit/services/server/test_config.py | 480 ++++++++++++++++++ .../tests/unit/services/server/test_server.py | 477 +++++++++++++++++ 6 files changed, 1549 insertions(+) create mode 100644 kmip/services/server/config.py create mode 100644 kmip/services/server/server.py create mode 100644 kmip/tests/unit/services/server/test_config.py create mode 100644 kmip/tests/unit/services/server/test_server.py diff --git a/kmip/core/exceptions.py b/kmip/core/exceptions.py index 4d6c541..c28b5bb 100644 --- a/kmip/core/exceptions.py +++ b/kmip/core/exceptions.py @@ -203,6 +203,22 @@ class PermissionDenied(KmipError): ) +class ConfigurationError(Exception): + """ + An error generated when a problem occurs with a client or server + configuration. + """ + pass + + +class NetworkingError(Exception): + """ + An error generated when a problem occurs with client or server networking + activity. + """ + pass + + class InvalidKmipEncoding(Exception): """ An exception raised when processing invalid KMIP message encodings. diff --git a/kmip/services/server/__init__.py b/kmip/services/server/__init__.py index 417e2f9..30225d3 100644 --- a/kmip/services/server/__init__.py +++ b/kmip/services/server/__init__.py @@ -12,3 +12,10 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + +from kmip.services.server.server import KmipServer + + +__all__ = [ + "KmipServer" +] diff --git a/kmip/services/server/config.py b/kmip/services/server/config.py new file mode 100644 index 0000000..0997aad --- /dev/null +++ b/kmip/services/server/config.py @@ -0,0 +1,226 @@ +# Copyright (c) 2016 The Johns Hopkins University/Applied Physics Laboratory +# 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. + +import logging +import os +import six + +from six.moves import configparser + +from kmip.core import exceptions + + +class KmipServerConfig(object): + """ + A configuration management tool for the KmipServer. + """ + + def __init__(self): + """ + Create a KmipServerConfig object. + """ + self._logger = logging.getLogger('kmip.server.config') + + self.settings = dict() + + self._expected_settings = [ + 'hostname', + 'port', + 'certificate_path', + 'key_path', + 'ca_path', + 'auth_suite' + ] + + def set_setting(self, setting, value): + """ + Set a specific setting value. + + This will overwrite the current setting value for the specified + setting. + + Args: + setting (string): The name of the setting to set (e.g., + 'certificate_path', 'hostname'). Required. + value (misc): The value of the setting to set. Type varies based + on setting. Required. + Raises: + ConfigurationError: Raised if the setting is not supported or if + the setting value is invalid. + """ + if setting not in self._expected_settings: + raise exceptions.ConfigurationError( + "Setting '{0}' is not supported.".format(setting) + ) + + if setting == 'hostname': + self._set_hostname(value) + elif setting == 'port': + self._set_port(value) + elif setting == 'certificate_path': + self._set_certificate_path(value) + elif setting == 'key_path': + self._set_key_path(value) + elif setting == 'ca_path': + self._set_ca_path(value) + else: + self._set_auth_suite(value) + + def load_settings(self, path): + """ + Load configuration settings from the file pointed to by path. + + This will overwrite all current setting values. + + Args: + path (string): The path to the configuration file containing + the settings to load. Required. + Raises: + ConfigurationError: Raised if the path does not point to an + existing file or if a setting value is invalid. + """ + if not os.path.exists(path): + raise exceptions.ConfigurationError( + "The server configuration file ('{0}') could not be " + "located.".format(path) + ) + + self._logger.info( + "Loading server configuration settings from: {0}".format(path) + ) + + parser = configparser.SafeConfigParser() + parser.read(path) + self._parse_settings(parser) + + def _parse_settings(self, parser): + if not parser.has_section('server'): + raise exceptions.ConfigurationError( + "The server configuration file does not have a 'server' " + "section." + ) + + settings = [x[0] for x in parser.items('server')] + for setting in settings: + if setting not in self._expected_settings: + raise exceptions.ConfigurationError( + "Setting '{0}' is not a supported setting. Please " + "remove it from the configuration file.".format(setting) + ) + for setting in self._expected_settings: + if setting not in settings: + raise exceptions.ConfigurationError( + "Setting '{0}' is missing from the configuration " + "file.".format(setting) + ) + + if parser.has_option('server', 'hostname'): + self._set_hostname(parser.get('server', 'hostname')) + if parser.has_option('server', 'port'): + self._set_port(parser.getint('server', 'port')) + if parser.has_option('server', 'certificate_path'): + self._set_certificate_path(parser.get( + 'server', + 'certificate_path') + ) + if parser.has_option('server', 'key_path'): + self._set_key_path(parser.get('server', 'key_path')) + if parser.has_option('server', 'ca_path'): + self._set_ca_path(parser.get('server', 'ca_path')) + if parser.has_option('server', 'auth_suite'): + self._set_auth_suite(parser.get('server', 'auth_suite')) + + def _set_hostname(self, value): + if isinstance(value, six.string_types): + self.settings['hostname'] = value + else: + raise exceptions.ConfigurationError( + "The hostname value must be a string." + ) + + def _set_port(self, value): + if isinstance(value, six.integer_types): + if 0 < value < 65535: + self.settings['port'] = value + else: + raise exceptions.ConfigurationError( + "The port value must be an integer in the range 0 - 65535." + ) + else: + raise exceptions.ConfigurationError( + "The port value must be an integer in the range 0 - 65535." + ) + + def _set_certificate_path(self, value): + if value is None: + self.settings['certificate_path'] = None + elif isinstance(value, six.string_types): + if os.path.exists(value): + self.settings['certificate_path'] = value + else: + raise exceptions.ConfigurationError( + "The certificate path value, if specified, must be a " + "valid string path to a certificate file." + ) + else: + raise exceptions.ConfigurationError( + "The certificate path value, if specified, must be a valid " + "string path to a certificate file." + ) + + def _set_key_path(self, value): + if value is None: + self.settings['key_path'] = None + elif isinstance(value, six.string_types): + if os.path.exists(value): + self.settings['key_path'] = value + else: + raise exceptions.ConfigurationError( + "The key path value, if specified, must be a valid string " + "path to a certificate key file." + ) + else: + raise exceptions.ConfigurationError( + "The key path value, if specified, must be a valid string " + "path to a certificate key file." + ) + + def _set_ca_path(self, value): + if value is None: + self.settings['ca_path'] = None + elif isinstance(value, six.string_types): + if os.path.exists(value): + self.settings['ca_path'] = value + else: + raise exceptions.ConfigurationError( + "The certificate authority (CA) path value, if " + "specified, must be a valid string path to a CA " + "certificate file." + ) + else: + raise exceptions.ConfigurationError( + "The certificate authority (CA) path value, if specified, " + "must be a valid string path to a CA certificate file." + ) + + def _set_auth_suite(self, value): + auth_suites = ['Basic', 'TLS1.2'] + if value not in auth_suites: + raise exceptions.ConfigurationError( + "The authentication suite must be one of the " + "following: Basic, TLS1.2" + ) + else: + self.settings['auth_suite'] = value diff --git a/kmip/services/server/server.py b/kmip/services/server/server.py new file mode 100644 index 0000000..059ff5d --- /dev/null +++ b/kmip/services/server/server.py @@ -0,0 +1,343 @@ +# Copyright (c) 2015 The Johns Hopkins University/Applied Physics Laboratory +# 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. + +import errno +import logging +import logging.handlers as handlers +import os +import signal +import socket +import ssl +import threading + +from kmip.core import exceptions +from kmip.services import auth +from kmip.services.server import config +from kmip.services.server import engine +from kmip.services.server import session + + +class KmipServer(object): + """ + The public front-end for the entire KmipServer service. + + The KmipServer manages the server configuration and oversees the creation + of KmipSessions for all successful client connections. It creates the + KmipEngine used to process all KMIP requests and is in charge of safely + shutting down all server components upon receiving a termination signal. + """ + + def __init__( + self, + hostname=None, + port=None, + certificate_path=None, + key_path=None, + ca_path=None, + auth_suite=None, + config_path='/etc/pykmip/server.conf', + log_path='/var/log/pykmip/server.log'): + """ + Create a KmipServer. + + Settings are loaded initially from the configuration file located at + config_path, if specified. All other configuration options listed + below, if specified, will override the settings loaded from the + configuration file. + + A rotating file logger will be set up with the base log file located + at log_path. The server itself will handle rotating the log files as + the logs grow. The server process must have permission to read/write + to the specified log directory. + + The main KmipEngine request processor is created here, along with all + information required to manage KMIP client connections and sessions. + + Args: + hostname (string): The host address the server will be bound to + (e.g., '127.0.0.1'). Optional, defaults to None. + port (int): The port number the server will be bound to + (e.g., 5696). Optional, defaults to None. + certificate_path (string): The path to the server certificate file + (e.g., '/etc/pykmip/certs/server.crt'). Optional, defaults to + None. + key_path (string): The path to the server certificate key file + (e.g., '/etc/pykmip/certs/server.key'). Optional, defaults to + None. + ca_path (string): The path to the certificate authority (CA) + certificate file (e.g., '/etc/pykmip/certs/ca.crt'). Optional, + defaults to None. + auth_suite (string): A string value indicating the type of + authentication suite to use for establishing TLS connections. + Accepted values are: 'Basic', 'TLS1.2'. Optional, defaults to + None. + config_path (string): The path to the server configuration file + (e.g., '/etc/pykmip/server.conf'). Optional, defaults to + '/etc/pykmip/server.conf'. + log_path (string): The path to the base server log file + (e.g., '/var/log/pykmip/server.log'). Optional, defaults to + '/var/log/pykmip/server.log'. + """ + self._logger = logging.getLogger('kmip.server') + self._setup_logging(log_path) + + self.config = config.KmipServerConfig() + self._setup_configuration( + config_path, + hostname, + port, + certificate_path, + key_path, + ca_path, + auth_suite + ) + + if self.config.settings.get('auth_suite') == 'TLS1.2': + self.auth_suite = auth.TLS12AuthenticationSuite() + else: + self.auth_suite = auth.BasicAuthenticationSuite() + + self._engine = engine.KmipEngine() + self._session_id = 1 + self._is_serving = False + + def _setup_logging(self, path): + # Create the logging directory/file if it doesn't exist. + if not os.path.exists(path): + if not os.path.isdir(os.path.dirname(path)): + os.makedirs(os.path.dirname(path)) + open(path, 'w').close() + + handler = handlers.RotatingFileHandler( + path, + mode='a', + maxBytes=1000000, + backupCount=5 + ) + handler.setFormatter( + logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + ) + self._logger.addHandler(handler) + self._logger.setLevel(logging.INFO) + + def _setup_configuration( + self, + path=None, + hostname=None, + port=None, + certificate_path=None, + key_path=None, + ca_path=None, + auth_suite=None): + if path: + self.config.load_settings(path) + + if hostname: + self.config.set_setting('hostname', hostname) + if port: + self.config.set_setting('port', port) + if certificate_path: + self.config.set_setting('certificate_path', certificate_path) + if key_path: + self.config.set_setting('key_path', key_path) + if ca_path: + self.config.set_setting('ca_path', ca_path) + if auth_suite: + self.config.set_setting('auth_suite', auth_suite) + + def start(self): + """ + Prepare the server to start serving connections. + + Configure the server socket handler and establish a TLS wrapping + socket from which all client connections descend. Bind this TLS + socket to the specified network address for the server. + + Raises: + NetworkingError: Raised if the TLS socket cannot be bound to the + network address. + """ + self._logger.info("Starting server socket handler.") + + # Create a TCP stream socket and configure it for immediate reuse. + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + self._socket = ssl.wrap_socket( + self._socket, + keyfile=self.config.settings.get('key_path'), + certfile=self.config.settings.get('certificate_path'), + server_side=True, + cert_reqs=ssl.CERT_REQUIRED, + ssl_version=self.auth_suite.protocol, + ca_certs=self.config.settings.get('ca_path'), + do_handshake_on_connect=True, + suppress_ragged_eofs=True, + ciphers=self.auth_suite.ciphers + ) + + try: + self._socket.bind( + ( + self.config.settings.get('hostname'), + int(self.config.settings.get('port')) + ) + ) + except Exception as e: + self._logger.exception(e) + raise exceptions.NetworkingError( + "Server failed to bind socket handler to {0}:{1}".format( + self.config.settings.get('hostname'), + self.config.settings.get('port') + ) + ) + else: + self._logger.info( + "Server successfully bound socket handler to {0}:{1}".format( + self.config.settings.get('hostname'), + self.config.settings.get('port') + ) + ) + self._is_serving = True + + def stop(self): + """ + Stop the server. + + Halt server client connections and clean up any existing connection + threads. + + Raises: + NetworkingError: Raised if a failure occurs while sutting down + or closing the TLS server socket. + """ + self._logger.info("Cleaning up remaining connection threads.") + + for thread in threading.enumerate(): + if thread is not threading.current_thread(): + try: + thread.join(10.0) + except Exception as e: + self._logger.info( + "Error occurred while attempting to cleanup thread: " + "{0}".format(thread.name) + ) + self._logger.exception(e) + else: + if thread.is_alive(): + self._logger.warning( + "Cleanup failed for thread: {0}. Thread is " + "still alive".format(thread.name) + ) + else: + self._logger.info( + "Cleanup succeeded for thread: {0}".format( + thread.name + ) + ) + + self._logger.info("Shutting down server socket handler.") + try: + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + except Exception as e: + self._logger.exception(e) + raise exceptions.NetworkingError( + "Server failed to shutdown socket handler." + ) + + def serve(self): + """ + Serve client connections. + + Begin listening for client connections, spinning off new KmipSessions + as connections are handled. Set up signal handling to shutdown + connection service as needed. + """ + self._socket.listen(5) + + def _signal_handler(signal_number, stack_frame): + self._is_serving = False + + signal.signal(signal.SIGINT, _signal_handler) + signal.signal(signal.SIGTERM, _signal_handler) + + self._logger.info("Starting connection service...") + + while self._is_serving: + try: + connection, address = self._socket.accept() + except socket.error as e: + if e.errno == errno.EINTR: + self._logger.warning("Interrupting connection service.") + else: + self._logger.warning( + "Error detected while establishing new connection." + ) + self._logger.exception(e) + break + except Exception as e: + self._logger.warning( + "Error detected while establishing new connection." + ) + self._logger.exception(e) + else: + self._setup_connection_handler(connection, address) + + self._logger.info("Stopping connection service.") + + def _setup_connection_handler(self, connection, address): + self._logger.info( + "Receiving incoming connection from: {0}:{1}".format( + address[0], + address[1] + ) + ) + + session_name = "{0:08}".format(self._session_id) + self._session_id += 1 + + self._logger.info( + "Dedicating session {0} to {1}:{2}".format( + session_name, + address[0], + address[1] + ) + ) + + try: + s = session.KmipSession( + self._engine, + connection, + name=session_name + ) + s.daemon = True + s.start() + except Exception as e: + self._logger.warning( + "Failure occurred while starting session: {0}".format( + session_name + ) + ) + self._logger.exception(e) + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.stop() diff --git a/kmip/tests/unit/services/server/test_config.py b/kmip/tests/unit/services/server/test_config.py new file mode 100644 index 0000000..1081985 --- /dev/null +++ b/kmip/tests/unit/services/server/test_config.py @@ -0,0 +1,480 @@ +# Copyright (c) 2016 The Johns Hopkins University/Applied Physics Laboratory +# 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. + +import mock + +from six.moves import configparser + +import testtools + +from kmip.core import exceptions +from kmip.services.server import config + + +class TestKmipServerConfig(testtools.TestCase): + """ + A test suite for the KmipServerConfig tool. + """ + + def setUp(self): + super(TestKmipServerConfig, self).setUp() + + def tearDown(self): + super(TestKmipServerConfig, self).tearDown() + + def test_init(self): + """ + Test that a KmipServerConfig object can be created without error. + """ + config.KmipServerConfig() + + def test_set_setting(self): + """ + Test that the right errors are raised and methods are called when + setting individual settings. + """ + c = config.KmipServerConfig() + + c._set_auth_suite = mock.MagicMock() + c._set_ca_path = mock.MagicMock() + c._set_certificate_path = mock.MagicMock() + c._set_hostname = mock.MagicMock() + c._set_key_path = mock.MagicMock() + c._set_port = mock.MagicMock() + + # Test the right error is generated when setting an unsupported + # setting. + args = ('invalid', None) + regex = "Setting 'invalid' is not supported." + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c.set_setting, + *args + ) + + # Test the right methods are called when setting supported settings. + c.set_setting('hostname', '127.0.0.1') + c._set_hostname.assert_called_once_with('127.0.0.1') + + c.set_setting('port', 5696) + c._set_port.assert_called_once_with(5696) + + c.set_setting('certificate_path', '/etc/pykmip/certs/server.crt') + c._set_certificate_path.assert_called_once_with( + '/etc/pykmip/certs/server.crt' + ) + + c.set_setting('key_path', '/etc/pykmip/certs/server.key') + c._set_key_path.assert_called_once_with( + '/etc/pykmip/certs/server.key' + ) + + c.set_setting('ca_path', '/etc/pykmip/certs/ca.crt') + c._set_ca_path.assert_called_once_with('/etc/pykmip/certs/ca.crt') + + c.set_setting('auth_suite', 'Basic') + c._set_auth_suite.assert_called_once_with('Basic') + + def test_load_settings(self): + """ + Test that the right calls are made and the right errors generated when + loading configuration settings from a configuration file specified by + a path string. + """ + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + c._parse_settings = mock.MagicMock() + + # Test that the right calls are made when correctly processing the + # configuration file. + with mock.patch('os.path.exists') as os_mock: + os_mock.return_value = True + with mock.patch( + 'six.moves.configparser.SafeConfigParser.read' + ) as parser_mock: + c.load_settings("/test/path/server.conf") + c._logger.info.assert_any_call( + "Loading server configuration settings from: " + "/test/path/server.conf" + ) + parser_mock.assert_called_with("/test/path/server.conf") + self.assertTrue(c._parse_settings.called) + + # Test that a ConfigurationError is generated when the path is invalid. + c._logger.reset_mock() + + with mock.patch('os.path.exists') as os_mock: + os_mock.return_value = False + args = ('/test/path/server.conf', ) + self.assertRaises( + exceptions.ConfigurationError, + c.load_settings, + *args + ) + + def test_parse_settings(self): + """ + Test that the right methods are called and the right errors generated + when parsing the configuration settings. + """ + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + + c._set_auth_suite = mock.MagicMock() + c._set_ca_path = mock.MagicMock() + c._set_certificate_path = mock.MagicMock() + c._set_hostname = mock.MagicMock() + c._set_key_path = mock.MagicMock() + c._set_port = mock.MagicMock() + + # Test that the right calls are made when correctly parsing settings. + parser = configparser.SafeConfigParser() + parser.add_section('server') + parser.set('server', 'hostname', '127.0.0.1') + parser.set('server', 'port', '5696') + parser.set('server', 'certificate_path', '/test/path/server.crt') + parser.set('server', 'key_path', '/test/path/server.key') + parser.set('server', 'ca_path', '/test/path/ca.crt') + parser.set('server', 'auth_suite', 'Basic') + + c._parse_settings(parser) + + c._set_hostname.assert_called_once_with('127.0.0.1') + c._set_port.assert_called_once_with(5696) + c._set_certificate_path.assert_called_once_with( + '/test/path/server.crt' + ) + c._set_key_path.assert_called_once_with('/test/path/server.key') + c._set_ca_path.assert_called_once_with('/test/path/ca.crt') + c._set_auth_suite.assert_called_once_with('Basic') + + # Test that a ConfigurationError is generated when the expected + # section is missing. + parser = configparser.SafeConfigParser() + + args = (parser, ) + regex = ( + "The server configuration file does not have a 'server' section." + ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._parse_settings, + *args + ) + + # Test that a ConfigurationError is generated when an unexpected + # setting is provided. + parser = configparser.SafeConfigParser() + parser.add_section('server') + parser.set('server', 'invalid', 'invalid') + + args = (parser, ) + regex = ( + "Setting 'invalid' is not a supported setting. Please remove it " + "from the configuration file." + ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._parse_settings, + *args + ) + + # Test that a ConfigurationError is generated when an expected + # setting is missing. + parser = configparser.SafeConfigParser() + parser.add_section('server') + + args = (parser, ) + regex = ( + "Setting 'hostname' is missing from the configuration file." + ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._parse_settings, + *args + ) + + # Test that a ConfigurationError is generated when an expected + # setting is missing. + parser = configparser.SafeConfigParser() + parser.add_section('server') + + args = (parser, ) + regex = ( + "Setting 'hostname' is missing from the configuration file." + ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._parse_settings, + *args + ) + + def test_set_hostname(self): + """ + Test that the hostname configuration property can be set correctly. + """ + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + + # Test that the setting is set correctly with a valid value. + c._set_hostname('127.0.0.1') + self.assertIn('hostname', c.settings.keys()) + self.assertEqual('127.0.0.1', c.settings.get('hostname')) + + # Test that a ConfigurationError is generated when setting the wrong + # value. + args = (0, ) + regex = "The hostname value must be a string." + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._set_hostname, + *args + ) + self.assertNotEqual(0, c.settings.get('hostname')) + + def test_set_port(self): + """ + Test that the port configuration property can be set correctly. + """ + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + + # Test that the setting is set correctly with a valid value. + c._set_port(5696) + self.assertIn('port', c.settings.keys()) + self.assertEqual(5696, c.settings.get('port')) + + # Test that a ConfigurationError is generated when setting the wrong + # value. + args = ('invalid', ) + regex = "The port value must be an integer in the range 0 - 65535." + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._set_port, + *args + ) + self.assertNotEqual('invalid', c.settings.get('port')) + + args = (65536, ) + regex = "The port value must be an integer in the range 0 - 65535." + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._set_port, + *args + ) + self.assertNotEqual(65536, c.settings.get('port')) + + def test_set_certificate_path(self): + """ + Test that the certificate_path configuration property can be set + correctly. + """ + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + + self.assertNotIn('certificate_path', c.settings.keys()) + + # Test that the setting is set correctly with a valid value. + with mock.patch('os.path.exists') as os_mock: + os_mock.return_value = True + c._set_certificate_path('/test/path/server.crt') + + self.assertIn('certificate_path', c.settings.keys()) + self.assertEqual( + '/test/path/server.crt', + c.settings.get('certificate_path') + ) + + c._set_certificate_path(None) + self.assertIn('certificate_path', c.settings.keys()) + self.assertEqual(None, c.settings.get('certificate_path')) + + # Test that a ConfigurationError is generated when setting the wrong + # value. + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + args = (0, ) + regex = ( + "The certificate path value, if specified, must be a valid " + "string path to a certificate file." + ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._set_certificate_path, + *args + ) + self.assertNotEqual(0, c.settings.get('certificate_path')) + + args = ('/test/path/server.crt', ) + regex = ( + "The certificate path value, if specified, must be a valid " + "string path to a certificate file." + ) + with mock.patch('os.path.exists') as os_mock: + os_mock.return_value = False + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._set_certificate_path, + *args + ) + self.assertNotEqual( + '/test/path/server.crt', + c.settings.get('certificate_path') + ) + + def test_set_key_path(self): + """ + Test that the key_path configuration property can be set correctly. + """ + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + + self.assertNotIn('key_path', c.settings.keys()) + + # Test that the setting is set correctly with a valid value. + with mock.patch('os.path.exists') as os_mock: + os_mock.return_value = True + c._set_key_path('/test/path/server.key') + + self.assertIn('key_path', c.settings.keys()) + self.assertEqual( + '/test/path/server.key', + c.settings.get('key_path') + ) + + c._set_key_path(None) + self.assertIn('key_path', c.settings.keys()) + self.assertEqual(None, c.settings.get('key_path')) + + # Test that a ConfigurationError is generated when setting the wrong + # value. + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + args = (0, ) + regex = ( + "The key path value, if specified, must be a valid string path " + "to a certificate key file." + ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._set_key_path, + *args + ) + self.assertNotEqual(0, c.settings.get('key_path')) + + args = ('/test/path/server.key', ) + regex = ( + "The key path value, if specified, must be a valid string path " + "to a certificate key file." + ) + with mock.patch('os.path.exists') as os_mock: + os_mock.return_value = False + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._set_key_path, + *args + ) + self.assertNotEqual( + '/test/path/server.key', + c.settings.get('key_path') + ) + + def test_set_ca_path(self): + """ + Test that the ca_path configuration property can be set correctly. + """ + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + + self.assertNotIn('ca_path', c.settings.keys()) + + # Test that the setting is set correctly with a valid value. + with mock.patch('os.path.exists') as os_mock: + os_mock.return_value = True + c._set_ca_path('/test/path/ca.crt') + + self.assertIn('ca_path', c.settings.keys()) + self.assertEqual( + '/test/path/ca.crt', + c.settings.get('ca_path') + ) + + c._set_ca_path(None) + self.assertIn('ca_path', c.settings.keys()) + self.assertEqual(None, c.settings.get('ca_path')) + + # Test that a ConfigurationError is generated when setting the wrong + # value. + c._logger.reset_mock() + args = (0, ) + self.assertRaises( + exceptions.ConfigurationError, + c._set_ca_path, + *args + ) + self.assertNotEqual(0, c.settings.get('ca_path')) + + args = ('/test/path/ca.crt', ) + with mock.patch('os.path.exists') as os_mock: + os_mock.return_value = False + self.assertRaises( + exceptions.ConfigurationError, + c._set_ca_path, + *args + ) + self.assertNotEqual(0, c.settings.get('ca_path')) + + def test_set_auth_suite(self): + """ + Test that the auth_suite configuration property can be set correctly. + """ + c = config.KmipServerConfig() + c._logger = mock.MagicMock() + + # Test that the setting is set correctly with a valid value. + c._set_auth_suite('Basic') + self.assertIn('auth_suite', c.settings.keys()) + self.assertEqual('Basic', c.settings.get('auth_suite')) + + c._set_auth_suite('TLS1.2') + self.assertIn('auth_suite', c.settings.keys()) + self.assertEqual('TLS1.2', c.settings.get('auth_suite')) + + # Test that a ConfigurationError is generated when setting the wrong + # value. + args = ('invalid', ) + regex = ( + "The authentication suite must be one of the following: " + "Basic, TLS1.2" + ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + regex, + c._set_auth_suite, + *args + ) + self.assertNotEqual('invalid', c.settings.get('auth_suite')) diff --git a/kmip/tests/unit/services/server/test_server.py b/kmip/tests/unit/services/server/test_server.py new file mode 100644 index 0000000..5ad251f --- /dev/null +++ b/kmip/tests/unit/services/server/test_server.py @@ -0,0 +1,477 @@ +# Copyright (c) 2016 The Johns Hopkins University/Applied Physics Laboratory +# 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. + +import errno +import logging +import mock +import signal +import socket +import testtools + +from kmip.core import exceptions +from kmip.services import auth +from kmip.services.server import server + + +class TestKmipServer(testtools.TestCase): + """ + A test suite for the KmipServer. + """ + + def setUp(self): + super(TestKmipServer, self).setUp() + + def tearDown(self): + super(TestKmipServer, self).tearDown() + + @mock.patch('kmip.services.server.server.KmipServer._setup_logging') + @mock.patch('kmip.services.server.server.KmipServer._setup_configuration') + def test_init(self, config_mock, logging_mock): + """ + Test that a KmipServer can be instantiated without error. + """ + s = server.KmipServer() + self.assertTrue(config_mock.called) + self.assertTrue(logging_mock.called) + + self.assertIsInstance(s.auth_suite, auth.BasicAuthenticationSuite) + self.assertIsNotNone(s._engine) + self.assertEqual(1, s._session_id) + self.assertFalse(s._is_serving) + + @mock.patch('logging.getLogger', side_effect=mock.MagicMock()) + @mock.patch('logging.handlers.RotatingFileHandler') + @mock.patch('kmip.services.server.server.KmipServer._setup_configuration') + @mock.patch('os.path.exists') + @mock.patch('os.path.isdir') + @mock.patch('os.makedirs') + def test_setup_logging( + self, + makedirs_mock, + isdir_mock, + path_mock, + config_mock, + handler_mock, + logging_mock): + """ + Verify that the server logger is setup correctly. + """ + path_mock.return_value = False + isdir_mock.return_value = False + open_mock = mock.mock_open() + + # Dynamically mock out the built-in open function. Approach changes + # across Python versions. + try: + import io # NOQA + module = 'kmip.services.server.server' + except: + module = '__builtin__' + + with mock.patch('{0}.open'.format(module), open_mock): + s = server.KmipServer(log_path='/test/path/server.log') + + path_mock.assert_called_once_with('/test/path/server.log') + isdir_mock.assert_called_once_with('/test/path') + makedirs_mock.assert_called_once_with('/test/path') + open_mock.assert_called_once_with('/test/path/server.log', 'w') + + self.assertTrue(s._logger.addHandler.called) + s._logger.setLevel.assert_called_once_with(logging.INFO) + + @mock.patch('kmip.services.auth.TLS12AuthenticationSuite') + @mock.patch('kmip.services.server.server.KmipServer._setup_logging') + def test_setup_configuration(self, logging_mock, auth_mock): + """ + Test that the server setup configuration works without error. + """ + s = server.KmipServer(config_path=None) + s.config = mock.MagicMock() + + # Test the right calls are made when reinvoking config setup + s._setup_configuration( + '/etc/pykmip/server.conf', + '127.0.0.1', + 5696, + '/etc/pykmip/certs/server.crt', + '/etc/pykmip/certs/server.key', + '/etc/pykmip/certs/ca.crt', + 'Basic' + ) + + s.config.load_settings.assert_called_with('/etc/pykmip/server.conf') + s.config.set_setting.assert_any_call('hostname', '127.0.0.1') + s.config.set_setting.assert_any_call('port', 5696) + s.config.set_setting.assert_any_call( + 'certificate_path', + '/etc/pykmip/certs/server.crt' + ) + s.config.set_setting.assert_any_call( + 'key_path', + '/etc/pykmip/certs/server.key' + ) + s.config.set_setting.assert_any_call( + 'ca_path', + '/etc/pykmip/certs/ca.crt' + ) + s.config.set_setting.assert_any_call('auth_suite', 'Basic') + + # Test that an attempt is made to instantiate the TLS 1.2 auth suite + s = server.KmipServer(auth_suite='TLS1.2', config_path=None) + self.assertEqual('TLS1.2', s.config.settings.get('auth_suite')) + self.assertIsNotNone(s.auth_suite) + + @mock.patch('kmip.services.server.server.KmipServer._setup_logging') + def test_start(self, logging_mock): + """ + Test that starting the KmipServer either runs as expected or generates + the expected error. + """ + a_mock = mock.MagicMock() + b_mock = mock.MagicMock() + + s = server.KmipServer( + hostname='127.0.0.1', + port=5696, + config_path=None + ) + s._logger = mock.MagicMock() + + self.assertFalse(s._is_serving) + + # Test that in ideal cases no errors are generated and the right + # log messages are. + with mock.patch('socket.socket') as socket_mock: + with mock.patch('ssl.wrap_socket') as ssl_mock: + socket_mock.return_value = a_mock + ssl_mock.return_value = b_mock + + s.start() + s._logger.info.assert_any_call( + "Starting server socket handler." + ) + socket_mock.assert_called_once_with( + socket.AF_INET, + socket.SOCK_STREAM + ) + a_mock.setsockopt.assert_called_once_with( + socket.SOL_SOCKET, + socket.SO_REUSEADDR, + 1 + ) + self.assertTrue(ssl_mock.called) + b_mock.bind.assert_called_once_with(('127.0.0.1', 5696)) + s._logger.info.assert_called_with( + "Server successfully bound socket handler to " + "127.0.0.1:5696" + ) + + self.assertTrue(s._is_serving) + + a_mock.reset_mock() + b_mock.reset_mock() + + # Test that a NetworkingError is generated if the socket bind fails. + with mock.patch('socket.socket') as socket_mock: + with mock.patch('ssl.wrap_socket') as ssl_mock: + socket_mock.return_value = a_mock + ssl_mock.return_value = b_mock + + test_exception = Exception() + b_mock.bind.side_effect = test_exception + + regex = ( + "Server failed to bind socket handler to 127.0.0.1:5696" + ) + self.assertRaisesRegexp( + exceptions.NetworkingError, + regex, + s.start + ) + s._logger.info.assert_any_call( + "Starting server socket handler." + ) + s._logger.exception.assert_called_once_with(test_exception) + + @mock.patch('kmip.services.server.server.KmipServer._setup_logging') + def test_stop(self, logging_mock): + """ + Test that the right calls and log messages are triggered while + cleaning up the server and any remaining sessions. + """ + s = server.KmipServer( + hostname='127.0.0.1', + port=5696, + config_path=None + ) + s._logger = mock.MagicMock() + s._socket = mock.MagicMock() + + # Test the expected behavior for a normal server stop sequence + thread_mock = mock.MagicMock() + thread_mock.join = mock.MagicMock() + thread_mock.is_alive = mock.MagicMock(return_value=False) + thread_mock.name = 'TestThread' + + with mock.patch('threading.enumerate') as threading_mock: + threading_mock.return_value = [thread_mock] + + s.stop() + s._logger.info.assert_any_call( + "Cleaning up remaining connection threads." + ) + self.assertTrue(threading_mock.called) + thread_mock.join.assert_called_once_with(10.0) + s._logger.info.assert_any_call( + "Cleanup succeeded for thread: TestThread" + ) + s._logger.info.assert_any_call( + "Shutting down server socket handler." + ) + s._socket.shutdown.assert_called_once_with(socket.SHUT_RDWR) + s._socket.close.assert_called_once_with() + + # Test the expected behavior when stopping multiple server session + # threads goes wrong + thread_mock.reset_mock() + test_exception = Exception() + thread_mock.join = mock.MagicMock(side_effect=test_exception) + + s._logger.reset_mock() + s._socket.reset_mock() + + with mock.patch('threading.enumerate') as threading_mock: + threading_mock.return_value = [thread_mock] + + s.stop() + s._logger.info.assert_any_call( + "Cleaning up remaining connection threads." + ) + self.assertTrue(threading_mock.called) + thread_mock.join.assert_called_once_with(10.0) + s._logger.info.assert_any_call( + "Error occurred while attempting to cleanup thread: TestThread" + ) + s._logger.exception.assert_called_once_with(test_exception) + s._logger.info.assert_any_call( + "Shutting down server socket handler." + ) + s._socket.shutdown.assert_called_once_with(socket.SHUT_RDWR) + s._socket.close.assert_called_once_with() + + thread_mock.reset_mock() + test_exception = Exception() + thread_mock.join = mock.MagicMock() + thread_mock.is_alive = mock.MagicMock(return_value=True) + + s._logger.reset_mock() + s._socket.reset_mock() + + with mock.patch('threading.enumerate') as threading_mock: + threading_mock.return_value = [thread_mock] + + s.stop() + s._logger.info.assert_any_call( + "Cleaning up remaining connection threads." + ) + self.assertTrue(threading_mock.called) + thread_mock.join.assert_called_once_with(10.0) + s._logger.warning.assert_any_call( + "Cleanup failed for thread: TestThread. Thread is still alive" + ) + s._logger.info.assert_any_call( + "Shutting down server socket handler." + ) + s._socket.shutdown.assert_called_once_with(socket.SHUT_RDWR) + s._socket.close.assert_called_once_with() + + # Test that the right errors and log messages are generated when + # stopping the server goes wrong + s._logger.reset_mock() + s._socket.reset_mock() + + test_exception = Exception() + s._socket.close = mock.MagicMock(side_effect=test_exception) + + regex = "Server failed to shutdown socket handler." + self.assertRaisesRegexp( + exceptions.NetworkingError, + regex, + s.stop + ) + s._logger.info.assert_any_call( + "Cleaning up remaining connection threads." + ) + s._logger.info.assert_any_call( + "Shutting down server socket handler." + ) + s._socket.shutdown.assert_called_once_with(socket.SHUT_RDWR) + s._socket.close.assert_called_once_with() + s._logger.exception(test_exception) + + @mock.patch('kmip.services.server.server.KmipServer._setup_logging') + def test_serve(self, logging_mock): + """ + Test that the right calls and log messages are triggered while + serving connections. + """ + s = server.KmipServer( + hostname='127.0.0.1', + port=5696, + config_path=None + ) + s._is_serving = True + s._logger = mock.MagicMock() + s._socket = mock.MagicMock() + s._setup_connection_handler = mock.MagicMock() + + expected_error = socket.error() + expected_error.errno = errno.EINTR + + # Test the expected behavior for a normal server/interrupt sequence + s._socket.accept = mock.MagicMock( + side_effect=[('connection', 'address'), expected_error] + ) + + s.serve() + s._socket.listen.assert_called_once_with(5) + s._socket.accept.assert_any_call() + s._setup_connection_handler.assert_called_once_with( + 'connection', + 'address' + ) + s._logger.warning.assert_called_with( + "Interrupting connection service." + ) + s._logger.info.assert_called_with("Stopping connection service.") + + # Test the behavior for an unexpected socket error. + unexpected_error = socket.error() + s._is_serving = True + s._logger.reset_mock() + s._socket.accept = mock.MagicMock( + side_effect=[unexpected_error] + ) + + s.serve() + s._socket.accept.assert_any_call() + s._logger.warning.assert_any_call( + "Error detected while establishing new connection." + ) + s._logger.exception.assert_called_with(unexpected_error) + s._logger.info.assert_called_with("Stopping connection service.") + + # Test the behavior for an unexpected error. + unexpected_error = Exception() + s._is_serving = True + s._logger.reset_mock() + s._socket.accept = mock.MagicMock( + side_effect=[unexpected_error, expected_error] + ) + + s.serve() + s._socket.accept.assert_any_call() + s._logger.warning.assert_any_call( + "Error detected while establishing new connection." + ) + s._logger.exception.assert_called_with(unexpected_error) + s._logger.info.assert_called_with("Stopping connection service.") + + # Test the signal handler for each expected signal + s._is_serving = True + handler = signal.getsignal(signal.SIGINT) + handler(None, None) + self.assertFalse(s._is_serving) + + s._is_serving = True + handler = signal.getsignal(signal.SIGTERM) + handler(None, None) + self.assertFalse(s._is_serving) + + @mock.patch('kmip.services.server.server.KmipServer._setup_logging') + def test_setup_connection_handler(self, logging_mock): + """ + Test that a KmipSession can be successfully created and spun off from + the KmipServer. + """ + s = server.KmipServer( + hostname='127.0.0.1', + port=5696, + config_path=None + ) + s._logger = mock.MagicMock() + + # Test that the right calls and log messages are made when + # starting a new session. + with mock.patch( + 'kmip.services.server.session.KmipSession.start' + ) as session_mock: + address = ('127.0.0.1', 5696) + s._setup_connection_handler(None, address) + + s._logger.info.assert_any_call( + "Receiving incoming connection from: 127.0.0.1:5696" + ) + s._logger.info.assert_any_call( + "Dedicating session 00000001 to 127.0.0.1:5696" + ) + session_mock.assert_called_once_with() + + self.assertEqual(2, s._session_id) + + # Test that the right error messages are logged when the session + # fails to start. + test_exception = Exception() + with mock.patch( + 'kmip.services.server.session.KmipSession.start', + side_effect=test_exception + ) as session_mock: + address = ('127.0.0.1', 5696) + s._setup_connection_handler(None, address) + + s._logger.info.assert_any_call( + "Receiving incoming connection from: 127.0.0.1:5696" + ) + s._logger.info.assert_any_call( + "Dedicating session 00000001 to 127.0.0.1:5696" + ) + session_mock.assert_called_once_with() + s._logger.warning.assert_called_once_with( + "Failure occurred while starting session: 00000002" + ) + s._logger.exception.assert_called_once_with(test_exception) + + self.assertEqual(3, s._session_id) + + @mock.patch('kmip.services.server.server.KmipServer._setup_logging') + def test_as_context_manager(self, logging_mock): + """ + Test that the right methods are called when the KmipServer is used + as a context manager. + """ + s = server.KmipServer( + hostname='127.0.0.1', + port=5696, + config_path=None + ) + s._logger = mock.MagicMock() + s.start = mock.MagicMock() + s.stop = mock.MagicMock() + + with s: + pass + + self.assertTrue(s.start.called) + self.assertTrue(s.stop.called)