From 5ecf1d324d45e536590aac52f228f22a58a56b7a Mon Sep 17 00:00:00 2001 From: Amit Uniyal Date: Wed, 1 Nov 2023 05:58:46 +0000 Subject: [PATCH] enforce remote console shutdown - Adds a CONF option enforce_session_timeout - Adds Timer to close connection once token expire - refactor close_connection functionality - Fixes existing and adds new unit tests - Adds release note - Updates admin guide Change-Id: I5d7e8faf1d271e9dd98d24e825631246308e7141 --- doc/source/admin/remote-console-access.rst | 44 +++++++- nova/conf/consoleauth.py | 13 ++- nova/console/websocketproxy.py | 30 ++++- nova/objects/console_auth_token.py | 12 +- .../tests/unit/console/test_websocketproxy.py | 103 +++++++++++++++++- .../unit/objects/test_console_auth_token.py | 10 +- nova/tests/unit/objects/test_objects.py | 2 +- ...sole-session-timeout-6ee4cdaf130ac011.yaml | 12 ++ 8 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 releasenotes/notes/enforce-console-session-timeout-6ee4cdaf130ac011.yaml diff --git a/doc/source/admin/remote-console-access.rst b/doc/source/admin/remote-console-access.rst index 1ef75fc42234..85ba556cfd47 100644 --- a/doc/source/admin/remote-console-access.rst +++ b/doc/source/admin/remote-console-access.rst @@ -69,6 +69,31 @@ This particular example is illustrated below. :alt: noVNC process :width: 95% +Consoleauth configuration: +-------------------------- + +The consoleauth accepts following options: + +- :oslo.config:option:`consoleauth.token_ttl` +- :oslo.config:option:`consoleauth.enforce_session_timeout` + +.. code-block:: ini + + [consoleauth] + token_ttl = 1000 # default value is 600 second + enforce_session_timeout = True # default is False + + +Supported consoles: +------------------- + +* :ref:`vnc-console` +* :ref:`spice-console` +* :ref:`serial-console` +* :ref:`mks-console` + + +.. _vnc-console: noVNC-based VNC console ----------------------- @@ -294,6 +319,8 @@ be told where to find them. This requires editing :file:`nova.conf` to set. vencrypt_ca_certs=/etc/pki/nova-novncproxy/ca-cert.pem +.. _spice-console: + SPICE console ------------- @@ -376,9 +403,10 @@ for SPICE consoles. - :oslo.config:option:`spice.playback_compression` - :oslo.config:option:`spice.streaming_mode` +.. _serial-console: -Serial ------- +Serial console +-------------- Serial consoles provide an alternative to graphical consoles like VNC or SPICE. They work a little differently to graphical consoles so an example is @@ -468,9 +496,10 @@ There are some things to keep in mind when configuring these options: :program:`nova-serialproxy` service to determine where to connect to for proxying the console interaction. +.. _mks-console: -MKS ---- +MKS console +----------- MKS is the protocol used for accessing the console of a virtual machine running on VMware vSphere. It is very similar to VNC. Due to the architecture of the @@ -576,6 +605,13 @@ Frequently Asked Questions console connections, make sure that the value of ``novncproxy_base_url`` is set explicitly where the ``nova-novncproxy`` service is running. +- **Q: How do I know which nova config file to update to set a particular config option?** + + A: First, find out which nova-service is responsible for the change you want + to make, using ``ps -aux | grep nova``. Once the service is found, check the + status of the service via systemctl. In the status output, associated conf + files with respective paths will be listed. + References ---------- diff --git a/nova/conf/consoleauth.py b/nova/conf/consoleauth.py index 0ebadbb2415f..650bfa1010a9 100644 --- a/nova/conf/consoleauth.py +++ b/nova/conf/consoleauth.py @@ -32,7 +32,18 @@ The lifetime of a console auth token (in seconds). A console auth token is used in authorizing console access for a user. Once the auth token time to live count has elapsed, the token is considered expired. Expired tokens are then deleted. -""") +"""), + cfg.BoolOpt( + 'enforce_session_timeout', + default=False, + help=""" +Enable or disable enforce session timeout for VM console. + +This allows operators to enforce a console session timeout. +When set to True, Nova will automatically close the console session +at the server end once token_ttl expires, providing enhanced +control over console session duration. +"""), ] diff --git a/nova/console/websocketproxy.py b/nova/console/websocketproxy.py index 88979dd047e8..961d106cfce8 100644 --- a/nova/console/websocketproxy.py +++ b/nova/console/websocketproxy.py @@ -37,6 +37,9 @@ from nova import exception from nova.i18n import _ from nova import objects +from oslo_utils import timeutils +import threading + # Location of WebSockifyServer class in websockify v0.9.0 websockifyserver = importutils.try_import('websockify.websockifyserver') @@ -147,6 +150,20 @@ class NovaProxyRequestHandler(websockify.ProxyRequestHandler): return connect_info + def _close_connection(self, tsock, host, port): + """takes target socket and close the connection. + """ + try: + tsock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + finally: + if tsock.fileno() != -1: + tsock.close() + self.vmsg(_("%(host)s:%(port)s: " + "Websocket client or target closed") % + {'host': host, 'port': port}) + def new_websocket_client(self): """Called after a new WebSocket connection has been established.""" # Reopen the eventlet hub to make sure we don't share an epoll @@ -260,14 +277,15 @@ class NovaProxyRequestHandler(websockify.ProxyRequestHandler): # Start proxying try: + if CONF.consoleauth.enforce_session_timeout: + conn_timeout = connect_info.expires - timeutils.utcnow_ts() + LOG.info('%s seconds to terminate connection.', conn_timeout) + threading.Timer(conn_timeout, self._close_connection, + [tsock, host, port]).start() + self.do_proxy(tsock) except Exception: - if tsock: - tsock.shutdown(socket.SHUT_RDWR) - tsock.close() - self.vmsg(_("%(host)s:%(port)s: " - "Websocket client or target closed") % - {'host': host, 'port': port}) + self._close_connection(tsock, host, port) raise def socket(self, *args, **kwargs): diff --git a/nova/objects/console_auth_token.py b/nova/objects/console_auth_token.py index d701d7aa014f..281d95549871 100644 --- a/nova/objects/console_auth_token.py +++ b/nova/objects/console_auth_token.py @@ -20,6 +20,7 @@ from oslo_log import log as logging from oslo_utils import strutils from oslo_utils import timeutils from oslo_utils import uuidutils +from oslo_utils import versionutils from nova.db.main import api as db from nova import exception @@ -38,7 +39,9 @@ class ConsoleAuthToken(base.NovaTimestampObject, base.NovaObject): # Version 1.1: Add clean_expired_console_auths method. # The clean_expired_console_auths_for_host method # was deprecated. - VERSION = '1.1' + # Version 1.2: Add expires field. + # This is to see token expire time. + VERSION = '1.2' fields = { 'id': fields.IntegerField(), @@ -52,6 +55,7 @@ class ConsoleAuthToken(base.NovaTimestampObject, base.NovaObject): # database. A hash of the token is stored instead and is not a # field on the object. 'token': fields.StringField(nullable=False), + 'expires': fields.IntegerField(nullable=False), } @property @@ -77,6 +81,12 @@ class ConsoleAuthToken(base.NovaTimestampObject, base.NovaObject): else: return '%s?token=%s' % (self.access_url_base, self.token) + def obj_make_compatible(self, primitive, target_version): + super().obj_make_compatible(primitive, target_version) + target_version = versionutils.convert_version_to_tuple(target_version) + if target_version < (1, 2) and 'expires' in primitive: + primitive.pop('expires', None) + @staticmethod def _from_db_object(context, obj, db_obj): # NOTE(PaulMurray): token is not stored in the database but diff --git a/nova/tests/unit/console/test_websocketproxy.py b/nova/tests/unit/console/test_websocketproxy.py index 639623bbb58a..088ed8e64d2f 100644 --- a/nova/tests/unit/console/test_websocketproxy.py +++ b/nova/tests/unit/console/test_websocketproxy.py @@ -15,6 +15,7 @@ """Tests for nova websocketproxy.""" import copy +import fixtures import io import socket from unittest import mock @@ -143,6 +144,9 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): self.wh.do_proxy = mock.MagicMock() self.wh.headers = mock.MagicMock() + self.threading_timer_mock = self.useFixture( + fixtures.MockPatch('threading.Timer', mock.DEFAULT)).mock + fake_header = { 'cookie': 'token="123-456-789"', 'Origin': 'https://example.net:6080', @@ -207,6 +211,7 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): 'host': 'node1', 'port': '10000', 'console_type': 'novnc', + 'expires': '100', 'access_url_base': 'https://example.net:6080' } validate.return_value = objects.ConsoleAuthToken(**params) @@ -235,11 +240,13 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): 'host': 'node1', 'port': '10000', 'console_type': 'novnc', + 'expires': '100', 'access_url_base': 'https://[2001:db8::1]:6080' } validate.return_value = objects.ConsoleAuthToken(**params) - self.wh.socket.return_value = '' + tsock = mock.MagicMock() + self.wh.socket.return_value = tsock self.wh.path = "http://[2001:db8::1]/?token=123-456-789" self.wh.headers = self.fake_header_ipv6 @@ -247,7 +254,7 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): validate.assert_called_with(mock.ANY, "123-456-789") self.wh.socket.assert_called_with('node1', 10000, connect=True) - self.wh.do_proxy.assert_called_with('') + self.wh.do_proxy.assert_called_with(tsock) @mock.patch('nova.objects.ConsoleAuthToken.validate') def test_new_websocket_client_token_invalid(self, validate): @@ -273,6 +280,7 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): 'port': '10000', 'internal_access_path': 'vmid', 'console_type': 'novnc', + 'expires': '100', 'access_url_base': 'https://example.net:6080' } validate.return_value = objects.ConsoleAuthToken(**params) @@ -304,6 +312,7 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): 'port': '10000', 'internal_access_path': 'xxx', 'console_type': 'novnc', + 'expires': '100', 'access_url_base': 'https://example.net:6080' } validate.return_value = objects.ConsoleAuthToken(**params) @@ -409,7 +418,8 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): } validate.return_value = objects.ConsoleAuthToken(**params) - self.wh.socket.return_value = '' + tsock = mock.MagicMock() + self.wh.socket.return_value = tsock self.wh.path = "http://127.0.0.1/" self.wh.headers = self.fake_header_allowed_origin @@ -417,7 +427,7 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): validate.assert_called_with(mock.ANY, "123-456-789") self.wh.socket.assert_called_with('node1', 10000, connect=True) - self.wh.do_proxy.assert_called_with('') + self.wh.do_proxy.assert_called_with(tsock) @mock.patch('nova.console.websocketproxy.NovaProxyRequestHandler.' '_check_console_port') @@ -455,7 +465,9 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): } validate.return_value = objects.ConsoleAuthToken(**params) - self.wh.socket.return_value = '' + tsock = mock.MagicMock() + self.wh.socket.return_value = tsock + self.wh.path = "http://127.0.0.1/" self.wh.headers = self.fake_header_no_origin @@ -463,7 +475,7 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): validate.assert_called_with(mock.ANY, "123-456-789") self.wh.socket.assert_called_with('node1', 10000, connect=True) - self.wh.do_proxy.assert_called_with('') + self.wh.do_proxy.assert_called_with(tsock) @mock.patch('nova.console.websocketproxy.NovaProxyRequestHandler.' '_check_console_port') @@ -694,6 +706,85 @@ class NovaProxyRequestHandlerTestCase(test.NoDBTestCase): websocketproxy.NovaWebSocketProxy(ssl_minimum_version=minver) mock_select_ssl.assert_called_once_with(minver) + @mock.patch('nova.console.websocketproxy.NovaProxyRequestHandler.' + '_check_console_port') + @mock.patch('nova.objects.ConsoleAuthToken.validate') + def test_enforce_session_timeout_timer_called( + self, validate, check_port): + params = { + 'id': 1, + 'token': '123-456-789', + 'instance_uuid': uuids.instance, + 'host': 'node1', + 'port': '10000', + 'console_type': 'novnc', + 'expires': '100', + 'access_url_base': 'https://example.net:6080' + } + validate.return_value = objects.ConsoleAuthToken(**params) + + self.wh.socket.return_value = '' + self.wh.path = "http://127.0.0.1/?token=123-456-789" + self.wh.headers = self.fake_header + + # in CONF, set to enforce session timeout + self.flags(enforce_session_timeout=True, group='consoleauth') + self.wh.new_websocket_client() + self.threading_timer_mock.assert_called_once() + + @mock.patch('nova.console.websocketproxy.NovaProxyRequestHandler.' + '_check_console_port') + @mock.patch('nova.objects.ConsoleAuthToken.validate') + def test_enforce_session_timeout_timer_not_called( + self, validate, check_port): + params = { + 'id': 1, + 'token': '123-456-789', + 'instance_uuid': uuids.instance, + 'host': 'node1', + 'port': '10000', + 'console_type': 'novnc', + 'expires': '100', + 'access_url_base': 'https://example.net:6080' + } + validate.return_value = objects.ConsoleAuthToken(**params) + + self.wh.socket.return_value = '' + self.wh.path = "http://127.0.0.1/?token=123-456-789" + self.wh.headers = self.fake_header + + self.flags(enforce_session_timeout=False, group='consoleauth') + self.wh.new_websocket_client() + self.threading_timer_mock.assert_not_called() + + def test__close_connection(self): + tsock = mock.MagicMock() + self.wh.vmsg = mock.MagicMock() + host = 'node1' + port = '10000' + + self.wh._close_connection(tsock, host, port) + tsock.shutdown.assert_called_once_with(socket.SHUT_RDWR) + tsock.close.assert_called_once() + self.wh.vmsg.assert_called_once_with( + f"{host}:{port}: Websocket client or target closed") + + def test__close_connection_raise_OSError(self): + tsock = mock.MagicMock() + self.wh.vmsg = mock.MagicMock() + host = 'node1' + port = '10000' + + tsock.shutdown.side_effect = OSError("Error") + + self.wh._close_connection(tsock, host, port) + + tsock.shutdown.assert_called_once_with(socket.SHUT_RDWR) + tsock.close.assert_called_once() + + self.wh.vmsg.assert_called_once_with( + f"{host}:{port}: Websocket client or target closed") + class NovaWebsocketSecurityProxyTestCase(test.NoDBTestCase): diff --git a/nova/tests/unit/objects/test_console_auth_token.py b/nova/tests/unit/objects/test_console_auth_token.py index 9a0901e12a7f..4fe906c41384 100644 --- a/nova/tests/unit/objects/test_console_auth_token.py +++ b/nova/tests/unit/objects/test_console_auth_token.py @@ -53,7 +53,7 @@ class _TestConsoleAuthToken(object): expected = copy.deepcopy(fakes.fake_token_dict) del expected['token_hash'] - del expected['expires'] + expected['expires'] = expires expected['token'] = fakes.fake_token expected['console_type'] = console_type @@ -85,6 +85,14 @@ class _TestConsoleAuthToken(object): path) self.assertEqual(expected_url, url) + # verify auth_token 'expires' backward version compatibility + data = lambda x: x['nova_object.data'] + console_auth_obj_primitive = data(obj.obj_to_primitive()) + self.assertIn('expires', console_auth_obj_primitive) + obj.obj_make_compatible(console_auth_obj_primitive, '1.1') + self.assertIn('token', console_auth_obj_primitive) + self.assertNotIn('expires', console_auth_obj_primitive) + def test_authorize(self): self._test_authorize(fakes.fake_token_dict['console_type']) diff --git a/nova/tests/unit/objects/test_objects.py b/nova/tests/unit/objects/test_objects.py index 45c2941e1d41..edd3196a17b5 100644 --- a/nova/tests/unit/objects/test_objects.py +++ b/nova/tests/unit/objects/test_objects.py @@ -1087,7 +1087,7 @@ object_data = { 'CellMappingList': '1.1-496ef79bb2ab41041fff8bcb57996352', 'ComputeNode': '1.19-af6bd29a6c3b225da436a0d8487096f2', 'ComputeNodeList': '1.17-52f3b0962b1c86b98590144463ebb192', - 'ConsoleAuthToken': '1.1-8da320fb065080eb4d3c2e5c59f8bf52', + 'ConsoleAuthToken': '1.2-a4677c3576ad91eb02068a5cb9d38eaa', 'CpuDiagnostics': '1.0-d256f2e442d1b837735fd17dfe8e3d47', 'Destination': '1.4-3b440d29459e2c98987ad5b25ad1cb2c', 'DeviceBus': '1.0-77509ea1ea0dd750d5864b9bd87d3f9d', diff --git a/releasenotes/notes/enforce-console-session-timeout-6ee4cdaf130ac011.yaml b/releasenotes/notes/enforce-console-session-timeout-6ee4cdaf130ac011.yaml new file mode 100644 index 000000000000..fc22302903ee --- /dev/null +++ b/releasenotes/notes/enforce-console-session-timeout-6ee4cdaf130ac011.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + This is a security-enhancing feature that automatically closes console sessions + exceeding a defined timeout period. To enable this functionality, operators are + required to set the 'enforce_session_timeout' boolean configuration option to True. + + The enforcement is implemented via a timer mechanism, initiating when users access + the console and concluding upon the expiration of the set console token. + + This ensures the graceful closure of console sessions on the server side, aligning + with security best practices.