diff --git a/nova/cmd/novncproxy.py b/nova/cmd/novncproxy.py index 4b9f053bd7a6..954602542d7d 100644 --- a/nova/cmd/novncproxy.py +++ b/nova/cmd/novncproxy.py @@ -25,6 +25,7 @@ from nova.cmd import baseproxy import nova.conf from nova.conf import vnc from nova import config +from nova.console.securityproxy import rfb CONF = nova.conf.CONF @@ -36,6 +37,13 @@ def main(): CONF.set_default('web', '/usr/share/novnc') config.parse_args(sys.argv) + # TODO(stephenfin): Always enable the security proxy once we support RFB + # version 3.3, as used in XenServer. + security_proxy = None + if CONF.compute_driver != 'xenapi.XenAPIDriver': + security_proxy = rfb.RFBSecurityProxy() + baseproxy.proxy( host=CONF.vnc.novncproxy_host, - port=CONF.vnc.novncproxy_port) + port=CONF.vnc.novncproxy_port, + security_proxy=security_proxy) diff --git a/nova/console/securityproxy/rfb.py b/nova/console/securityproxy/rfb.py new file mode 100644 index 000000000000..6aea4712f947 --- /dev/null +++ b/nova/console/securityproxy/rfb.py @@ -0,0 +1,190 @@ +# Copyright (c) 2014-2016 Red Hat, Inc +# 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 struct + +from oslo_config import cfg +from oslo_log import log as logging +import six + +from nova.console.rfb import auth +from nova.console.rfb import auths +from nova.console.securityproxy import base +from nova import exception +from nova.i18n import _, _LI + +LOG = logging.getLogger(__name__) + +CONF = cfg.CONF + + +class RFBSecurityProxy(base.SecurityProxy): + """RFB Security Proxy Negotiation Helper. + + This class proxies the initial setup of the RFB connection between the + client and the server. Then, when the RFB security negotiation step + arrives, it intercepts the communication, posing as a server with the + "None" authentication type to the client, and acting as a client (via + the methods below) to the server. After security negotiation, normal + proxying can be used. + + Note: this code mandates RFB version 3.8, since this is supported by any + client and server impl written in the past 10+ years. + + See the general RFB specification at: + + https://tools.ietf.org/html/rfc6143 + """ + + def __init__(self): + self.auth_schemes = auths.RFBAuthSchemeList() + + def _make_var_str(self, message): + message_str = six.text_type(message) + message_bytes = message_str.encode('utf-8') + message_len = struct.pack("!I", len(message_bytes)) + return message_len + message_bytes + + def _fail(self, tenant_sock, compute_sock, message): + # Tell the client there's been a problem + result_code = struct.pack("!I", 1) + tenant_sock.sendall(result_code + self._make_var_str(message)) + + if compute_sock is not None: + # Tell the server that there's been a problem + # by sending the "Invalid" security type + compute_sock.sendall(auth.AUTH_STATUS_FAIL) + + def _parse_version(self, version_str): + maj_str = version_str[4:7] + min_str = version_str[8:11] + + return float("%d.%d" % (int(maj_str), int(min_str))) + + def connect(self, tenant_sock, compute_sock): + """Initiate the RFB connection process. + + This method performs the initial ProtocolVersion + and Security messaging, and returns the socket-like + object to use to communicate with the server securely. + If an error occurs SecurityProxyNegotiationFailed + will be raised. + """ + + def recv(sock, num): + b = sock.recv(num) + if len(b) != num: + reason = _("Incorrect read from socket, wanted %(wanted)d " + "bytes but got %(got)d. Socket returned " + "%(result)r") % {'wanted': num, 'got': len(b), + 'result': b} + raise exception.RFBAuthHandshakeFailed(reason=reason) + return b + + # Negotiate version with compute server + compute_version = recv(compute_sock, auth.VERSION_LENGTH) + LOG.debug("Got version string '%s' from compute node", + compute_version[:-1]) + + if self._parse_version(compute_version) != 3.8: + reason = _("Security proxying requires RFB protocol " + "version 3.8, but server sent %s"), compute_version[:-1] + raise exception.SecurityProxyNegotiationFailed(reason=reason) + compute_sock.sendall(compute_version) + + # Negotiate version with tenant + tenant_sock.sendall(compute_version) + tenant_version = recv(tenant_sock, auth.VERSION_LENGTH) + LOG.debug("Got version string '%s' from tenant", + tenant_version[:-1]) + + if self._parse_version(tenant_version) != 3.8: + reason = _("Security proxying requires RFB protocol version " + "3.8, but tenant asked for %s"), tenant_version[:-1] + raise exception.SecurityProxyNegotiationFailed(reason=reason) + + # Negotiate security with server + permitted_auth_types_cnt = six.byte2int(recv(compute_sock, 1)) + + if permitted_auth_types_cnt == 0: + reason_len_raw = recv(compute_sock, 4) + reason_len = struct.unpack('!I', reason_len_raw)[0] + reason = recv(compute_sock, reason_len) + + tenant_sock.sendall(auth.AUTH_STATUS_FAIL + + reason_len_raw + reason) + + raise exception.SecurityProxyNegotiationFailed(reason=reason) + + f = recv(compute_sock, permitted_auth_types_cnt) + permitted_auth_types = [] + for auth_type in f: + if isinstance(auth_type, six.string_types): + auth_type = ord(auth_type) + permitted_auth_types.append(auth_type) + + LOG.debug("The server sent security types %s", permitted_auth_types) + + # Negotiate security with client before we say "ok" to the server + # send 1:[None] + tenant_sock.sendall(auth.AUTH_STATUS_PASS + + six.int2byte(auth.AuthType.NONE)) + client_auth = six.byte2int(recv(tenant_sock, 1)) + + if client_auth != auth.AuthType.NONE: + self._fail(tenant_sock, compute_sock, + _("Only the security type None (%d) is supported") % + auth.AuthType.NONE) + + reason = _("Client requested a security type other than " + " None (%(none_code)d): " + "%(auth_type)s") % { + 'auth_type': client_auth, + 'none_code': auth.AuthType.NONE} + raise exception.SecurityProxyNegotiationFailed(reason=reason) + + try: + scheme = self.auth_schemes.find_scheme(permitted_auth_types) + except exception.RFBAuthNoAvailableScheme as e: + # Intentionally don't tell client what really failed + # as that's information leakage + self._fail(tenant_sock, compute_sock, + _("Unable to negotiate security with server")) + raise exception.SecurityProxyNegotiationFailed( + reason=_("No compute auth available: %s") % six.text_type(e)) + + compute_sock.sendall(six.int2byte(scheme.security_type())) + + LOG.debug("Using security type %d with server, None with client", + scheme.security_type()) + + try: + compute_sock = scheme.security_handshake(compute_sock) + except exception.RFBAuthHandshakeFailed as e: + # Intentionally don't tell client what really failed + # as that's information leakage + self._fail(tenant_sock, None, + _("Unable to negotiate security with server")) + LOG.debug("Auth failed %s", six.text_type(e)) + raise exception.SecurityProxyNegotiationFailed( + reason="Auth handshake failed") + + LOG.info(_LI("Finished security handshake, resuming normal proxy " + "mode using secured socket")) + + # we can just proxy the security result -- if the server security + # negotiation fails, we want the client to think it has failed + + return compute_sock diff --git a/nova/console/websocketproxy.py b/nova/console/websocketproxy.py index b87abb17bb00..d93d61ac6b69 100644 --- a/nova/console/websocketproxy.py +++ b/nova/console/websocketproxy.py @@ -80,7 +80,7 @@ class TenantSock(object): self.reqhandler.send_frames([encodeutils.safe_encode(data)]) def finish_up(self): - self.reqhandler.send_frames([b''.join([self.queue])]) + self.reqhandler.send_frames([b''.join(self.queue)]) def close(self): self.finish_up() diff --git a/nova/tests/unit/console/securityproxy/__init__.py b/nova/tests/unit/console/securityproxy/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/nova/tests/unit/console/securityproxy/test_rfb.py b/nova/tests/unit/console/securityproxy/test_rfb.py new file mode 100644 index 000000000000..e6c284e4d7f3 --- /dev/null +++ b/nova/tests/unit/console/securityproxy/test_rfb.py @@ -0,0 +1,248 @@ +# Copyright (c) 2014-2016 Red Hat, Inc +# 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. + +"""Tests the Console Security Proxy Framework.""" + +import six + +import mock + +from nova.console.rfb import auth +from nova.console.rfb import authnone +from nova.console.securityproxy import rfb +from nova import exception +from nova import test + + +class RFBSecurityProxyTestCase(test.NoDBTestCase): + """Test case for the base RFBSecurityProxy.""" + + def setUp(self): + super(RFBSecurityProxyTestCase, self).setUp() + self.manager = mock.Mock() + self.tenant_sock = mock.Mock() + self.compute_sock = mock.Mock() + + self.tenant_sock.recv.side_effect = [] + self.compute_sock.recv.side_effect = [] + + self.expected_manager_calls = [] + self.expected_tenant_calls = [] + self.expected_compute_calls = [] + + self.proxy = rfb.RFBSecurityProxy() + + def _assert_expected_calls(self): + self.assertEqual(self.expected_manager_calls, + self.manager.mock_calls) + self.assertEqual(self.expected_tenant_calls, + self.tenant_sock.mock_calls) + self.assertEqual(self.expected_compute_calls, + self.compute_sock.mock_calls) + + def _version_handshake(self): + full_version_str = "RFB 003.008\n" + + self._expect_compute_recv(auth.VERSION_LENGTH, full_version_str) + self._expect_compute_send(full_version_str) + + self._expect_tenant_send(full_version_str) + self._expect_tenant_recv(auth.VERSION_LENGTH, full_version_str) + + def _to_binary(self, val): + if type(val) != six.binary_type: + val = six.binary_type(val, 'utf-8') + return val + + def _expect_tenant_send(self, val): + val = self._to_binary(val) + self.expected_tenant_calls.append(mock.call.sendall(val)) + + def _expect_compute_send(self, val): + val = self._to_binary(val) + self.expected_compute_calls.append(mock.call.sendall(val)) + + def _expect_tenant_recv(self, amt, ret_val): + ret_val = self._to_binary(ret_val) + self.expected_tenant_calls.append(mock.call.recv(amt)) + self.tenant_sock.recv.side_effect = ( + list(self.tenant_sock.recv.side_effect) + [ret_val]) + + def _expect_compute_recv(self, amt, ret_val): + ret_val = self._to_binary(ret_val) + self.expected_compute_calls.append(mock.call.recv(amt)) + self.compute_sock.recv.side_effect = ( + list(self.compute_sock.recv.side_effect) + [ret_val]) + + def test_fail(self): + self._expect_tenant_send("\x00\x00\x00\x01\x00\x00\x00\x04blah") + + self.proxy._fail(self.tenant_sock, None, 'blah') + + self._assert_expected_calls() + + def test_fail_server_message(self): + self._expect_tenant_send("\x00\x00\x00\x01\x00\x00\x00\x04blah") + self._expect_compute_send("\x00") + + self.proxy._fail(self.tenant_sock, self.compute_sock, 'blah') + + self._assert_expected_calls() + + def test_parse_version(self): + res = self.proxy._parse_version("RFB 012.034\n") + self.assertEqual(12.34, res) + + def test_fails_on_compute_version(self): + for full_version_str in ["RFB 003.007\n", "RFB 003.009\n"]: + self._expect_compute_recv(auth.VERSION_LENGTH, full_version_str) + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.compute_sock) + self._assert_expected_calls() + + def test_fails_on_tenant_version(self): + full_version_str = "RFB 003.008\n" + + for full_version_str_invalid in ["RFB 003.007\n", "RFB 003.009\n"]: + self._expect_compute_recv(auth.VERSION_LENGTH, full_version_str) + self._expect_compute_send(full_version_str) + + self._expect_tenant_send(full_version_str) + self._expect_tenant_recv(auth.VERSION_LENGTH, + full_version_str_invalid) + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.compute_sock) + self._assert_expected_calls() + + def test_fails_on_sec_type_cnt_zero(self): + self.proxy._fail = mock.Mock() + + self._version_handshake() + + self._expect_compute_recv(1, "\x00") + self._expect_compute_recv(4, "\x00\x00\x00\x06") + self._expect_compute_recv(6, "cheese") + self._expect_tenant_send("\x00\x00\x00\x00\x06cheese") + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.compute_sock) + + self._assert_expected_calls() + + @mock.patch.object(authnone.RFBAuthSchemeNone, "security_handshake") + def test_full_run(self, mock_handshake): + new_sock = mock.MagicMock() + mock_handshake.return_value = new_sock + + self._version_handshake() + + self._expect_compute_recv(1, "\x02") + self._expect_compute_recv(2, "\x01\x02") + + self._expect_tenant_send("\x01\x01") + self._expect_tenant_recv(1, "\x01") + + self._expect_compute_send("\x01") + + self.assertEqual(new_sock, self.proxy.connect( + self.tenant_sock, self.compute_sock)) + + mock_handshake.assert_called_once_with(self.compute_sock) + self._assert_expected_calls() + + def test_client_auth_invalid_fails(self): + self.proxy._fail = self.manager.proxy._fail + self.proxy.security_handshake = self.manager.proxy.security_handshake + + self._version_handshake() + + self._expect_compute_recv(1, "\x02") + self._expect_compute_recv(2, "\x01\x02") + + self._expect_tenant_send("\x01\x01") + self._expect_tenant_recv(1, "\x02") + + self.expected_manager_calls.append( + mock.call.proxy._fail(self.tenant_sock, + self.compute_sock, + "Only the security type " + "None (1) is supported")) + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.compute_sock) + self._assert_expected_calls() + + def test_exception_in_choose_security_type_fails(self): + self.proxy._fail = self.manager.proxy._fail + self.proxy.security_handshake = self.manager.proxy.security_handshake + + self._version_handshake() + + self._expect_compute_recv(1, "\x02") + self._expect_compute_recv(2, "\x02\x05") + + self._expect_tenant_send("\x01\x01") + self._expect_tenant_recv(1, "\x01") + + self.expected_manager_calls.extend([ + mock.call.proxy._fail( + self.tenant_sock, self.compute_sock, + 'Unable to negotiate security with server')]) + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.compute_sock) + + self._assert_expected_calls() + + @mock.patch.object(authnone.RFBAuthSchemeNone, "security_handshake") + def test_exception_security_handshake_fails(self, mock_auth): + self.proxy._fail = self.manager.proxy._fail + + self._version_handshake() + + self._expect_compute_recv(1, "\x02") + self._expect_compute_recv(2, "\x01\x02") + + self._expect_tenant_send("\x01\x01") + self._expect_tenant_recv(1, "\x01") + + self._expect_compute_send("\x01") + + ex = exception.RFBAuthHandshakeFailed(reason="crackers") + mock_auth.side_effect = ex + + self.expected_manager_calls.extend([ + mock.call.proxy._fail(self.tenant_sock, None, + 'Unable to negotiate security with server')]) + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.compute_sock) + + mock_auth.assert_called_once_with(self.compute_sock) + self._assert_expected_calls() diff --git a/releasenotes/notes/websocket-proxy-to-host-security-c3eca0647b0cbc02.yaml b/releasenotes/notes/websocket-proxy-to-host-security-c3eca0647b0cbc02.yaml index 017bf386fd29..f9588c3b4b84 100644 --- a/releasenotes/notes/websocket-proxy-to-host-security-c3eca0647b0cbc02.yaml +++ b/releasenotes/notes/websocket-proxy-to-host-security-c3eca0647b0cbc02.yaml @@ -9,3 +9,26 @@ features: - ``vencrypt_client_key`` - ``vencrypt_client_cert`` - ``vencrypt_ca_certs`` + - | + The *nova-novncproxy* server can now be configured to do a security + negotiation with the compute node VNC server. If the VeNCrypt auth scheme + is enabled, this establishes a TLS session to provide encryption of all + data. The proxy will validate the x509 certs issued by the remote server to + ensure it is connecting to a valid compute node. The proxy can also send + its own x509 cert to allow the compute node to validate that the connection + comes from the official proxy server. +upgrade: + - | + To make use of VeNCrypt, configuration steps are required for both the + `nova-novncproxy` service and libvirt on all the compute nodes. The + ``/etc/libvirt/qemu.conf`` file should be modified to set the ``vnc_tls`` + option to ``1``, and optionally the ``vnc_tls_x509_verify`` option to + ``1``. Certificates must also be deployed on the compute node. + + The ``nova.conf`` file should have the ``auth_schemes`` parameter in the + ``vnc`` group set. If there are a mix of compute nodes, some with VeNCrypt + enabled and others with it disabled, then the ``auth_schemes`` + configuration option should be set to ``['vencrypt', 'none']``. + + Once all compute nodes have VeNCrypt enabled, the ``auth_schemes`` + parameter can be set to just ``['vencrypt']``.