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
This commit is contained in:
Amit Uniyal 2023-11-01 05:58:46 +00:00
parent 3209f65516
commit 5ecf1d324d
8 changed files with 206 additions and 20 deletions

View File

@ -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
----------

View File

@ -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.
"""),
]

View File

@ -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):

View File

@ -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

View File

@ -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 = '<socket>'
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('<socket>')
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 = '<socket>'
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('<socket>')
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 = '<socket>'
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('<socket>')
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 = '<socket>'
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 = '<socket>'
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):

View File

@ -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'])

View File

@ -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',

View File

@ -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.