console: introduce basic framework for security proxying

Introduce a framework to the websocketproxy to allow a security
negotiation to take place between the proxy and the target service,
prior to connecting the client tenant to the target service.

Based on earlier work by Solly Ross <sross@redhat.com>

Change-Id: Ifb9360be73864ab45129c758bd1323a9bab8e48c
Co-authored-by: Stephen Finucane <sfinucan@redhat.com>
Implements: bp websocket-proxy-to-host-security
This commit is contained in:
Daniel P. Berrange 2016-07-20 12:16:58 +01:00 committed by Stephen Finucane
parent ae4b5d0147
commit 2a04b4dadf
7 changed files with 221 additions and 5 deletions

View File

@ -40,7 +40,16 @@ def exit_with_error(msg, errno=-1):
sys.exit(errno)
def proxy(host, port):
def proxy(host, port, security_proxy=None):
""":param host: local address to listen on
:param port: local port to listen on
:param security_proxy: instance of
nova.console.securityproxy.base.SecurityProxy
Setup a proxy listening on @host:@port. If the
@security_proxy parameter is not None, this instance
is used to negotiate security layer with the proxy target
"""
if CONF.ssl_only and not os.path.exists(CONF.cert):
exit_with_error("SSL only and %s not found" % CONF.cert)
@ -66,5 +75,6 @@ def proxy(host, port):
traffic=not CONF.daemon,
web=CONF.web,
file_only=True,
RequestHandlerClass=websocketproxy.NovaProxyRequestHandler
RequestHandlerClass=websocketproxy.NovaProxyRequestHandler,
security_proxy=security_proxy,
).start_server()

View File

View File

@ -0,0 +1,47 @@
# 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 abc
import six
@six.add_metaclass(abc.ABCMeta)
class SecurityProxy(object):
"""A console security Proxy Helper
Console security proxy helpers should subclass
this class and implement a generic `connect`
for the particular protocol being used.
Security drivers can then subclass the
protocol-specific helper class.
"""
@abc.abstractmethod
def connect(self, tenant_sock, compute_sock):
"""Initiate the console connection
This method performs the protocol specific
negotiation, and returns the socket-like
object to use to communicate with the server
securely.
:param tenant_sock: socket connected to the remote tenant user
:param compute_sock: socket connected to the compute node instance
:returns: a new compute_sock for the instance
"""
pass

View File

@ -22,6 +22,7 @@ import socket
import sys
from oslo_log import log as logging
import six
from six.moves import http_cookies as Cookie
import six.moves.urllib.parse as urlparse
import websockify
@ -37,6 +38,54 @@ LOG = logging.getLogger(__name__)
CONF = nova.conf.CONF
class TenantSock(object):
"""A socket wrapper for communicating with the tenant.
This class provides a socket-like interface to the internal
websockify send/receive queue for the client connection to
the tenant user. It is used with the security proxy classes.
"""
def __init__(self, reqhandler):
self.reqhandler = reqhandler
self.queue = []
def recv(self, cnt):
# NB(sross): it's ok to block here because we know
# exactly the sequence of data arriving
while len(self.queue) < cnt:
# new_frames looks like ['abc', 'def']
new_frames, closed = self.reqhandler.recv_frames()
# flatten frames onto queue
for frame in new_frames:
# The socket returns (byte) strings in Python 2...
if six.PY2:
self.queue.extend(frame)
# ...and integers in Python 3. For the Python 3 case, we need
# to convert these to characters using 'chr' and then, as this
# returns unicode, convert the result to byte strings.
else:
self.queue.extend(
[six.binary_type(chr(c), 'ascii') for c in frame])
if closed:
break
popped = self.queue[0:cnt]
del self.queue[0:cnt]
return b''.join(popped)
def sendall(self, data):
self.reqhandler.send_frames([data])
def finish_up(self):
self.reqhandler.send_frames([b''.join([self.queue])])
def close(self):
self.finish_up()
self.reqhandler.send_close()
class NovaProxyRequestHandlerBase(object):
def address_string(self):
# NOTE(rpodolyaka): override the superclass implementation here and
@ -157,6 +206,21 @@ class NovaProxyRequestHandlerBase(object):
tsock.recv(token_loc + len(end_token))
break
if self.server.security_proxy is not None:
tenant_sock = TenantSock(self)
try:
tsock = self.server.security_proxy.connect(tenant_sock, tsock)
except exception.SecurityProxyNegotiationFailed:
LOG.exception("Unable to perform security proxying, shutting "
"down connection")
tenant_sock.close()
tsock.shutdown(socket.SHUT_RDWR)
tsock.close()
raise
tenant_sock.finish_up()
# Start proxying
try:
self.do_proxy(tsock)
@ -180,6 +244,17 @@ class NovaProxyRequestHandler(NovaProxyRequestHandlerBase,
class NovaWebSocketProxy(websockify.WebSocketProxy):
def __init__(self, *args, **kwargs):
""":param security_proxy: instance of
nova.console.securityproxy.base.SecurityProxy
Create a new web socket proxy, optionally using the
@security_proxy instance to negotiate security layer
with the compute node.
"""
self.security_proxy = kwargs.pop('security_proxy', None)
super(NovaWebSocketProxy, self).__init__(*args, **kwargs)
@staticmethod
def get_logger():
return LOG

View File

@ -1764,6 +1764,10 @@ class RequestedVRamTooHigh(NovaException):
"than the maximum allowed by flavor %(max_vram)d.")
class SecurityProxyNegotiationFailed(NovaException):
msg_fmt = _("Failed to negotiate security type with server: %(reason)s")
class InvalidWatchdogAction(Invalid):
msg_fmt = _("Provided watchdog action (%(action)s) is not supported.")

View File

@ -67,7 +67,7 @@ class BaseProxyTestCase(test.NoDBTestCase):
mock_init.assert_called_once_with(
listen_host='0.0.0.0', listen_port='6080', source_is_ipv6=False,
cert='self.pem', key=None, ssl_only=False,
daemon=False, record=None, traffic=True,
daemon=False, record=None, security_proxy=None, traffic=True,
web='/usr/share/spice-html5', file_only=True,
RequestHandlerClass=websocketproxy.NovaProxyRequestHandler)
mock_start.assert_called_once_with()

View File

@ -14,11 +14,10 @@
"""Tests for nova websocketproxy."""
import mock
import socket
from nova.console.securityproxy import base
from nova.console import websocketproxy
from nova import exception
from nova import test
@ -32,7 +31,9 @@ class NovaProxyRequestHandlerBaseTestCase(test.NoDBTestCase):
self.flags(allowed_origins=['allowed-origin-example-1.net',
'allowed-origin-example-2.net'],
group='console')
self.server = websocketproxy.NovaWebSocketProxy()
self.wh = websocketproxy.NovaProxyRequestHandlerBase()
self.wh.server = self.server
self.wh.socket = mock.MagicMock()
self.wh.msg = mock.MagicMock()
self.wh.do_proxy = mock.MagicMock()
@ -393,3 +394,82 @@ class NovaProxyRequestHandlerBaseTestCase(test.NoDBTestCase):
check_token.assert_called_with(mock.ANY, token="123-456-789")
self.wh.socket.assert_called_with('node1', 10000, connect=True)
self.wh.do_proxy.assert_called_with('<socket>')
class NovaWebsocketSecurityProxyTestCase(test.NoDBTestCase):
def setUp(self):
super(NovaWebsocketSecurityProxyTestCase, self).setUp()
self.flags(allowed_origins=['allowed-origin-example-1.net',
'allowed-origin-example-2.net'],
group='console')
self.server = websocketproxy.NovaWebSocketProxy(
security_proxy=mock.MagicMock(
spec=base.SecurityProxy)
)
self.wh = websocketproxy.NovaProxyRequestHandlerBase()
self.wh.server = self.server
self.wh.path = "http://127.0.0.1/?token=123-456-789"
self.wh.socket = mock.MagicMock()
self.wh.msg = mock.MagicMock()
self.wh.do_proxy = mock.MagicMock()
self.wh.headers = mock.MagicMock()
def get_header(header):
if header == 'cookie':
return 'token="123-456-789"'
elif header == 'Origin':
return 'https://example.net:6080'
elif header == 'Host':
return 'example.net:6080'
else:
return
self.wh.headers.get = get_header
@mock.patch('nova.console.websocketproxy.TenantSock.close')
@mock.patch('nova.console.websocketproxy.TenantSock.finish_up')
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token',
return_value=True)
def test_proxy_connect_ok(self, check_token, mock_finish, mock_close):
check_token.return_value = {
'host': 'node1',
'port': '10000',
'console_type': 'novnc',
'access_url': 'https://example.net:6080'
}
sock = mock.MagicMock(
spec=websocketproxy.TenantSock)
self.server.security_proxy.connect.return_value = sock
self.wh.new_websocket_client()
self.wh.do_proxy.assert_called_with(sock)
mock_finish.assert_called_with()
self.assertEqual(len(mock_close.calls), 0)
@mock.patch('nova.console.websocketproxy.TenantSock.close')
@mock.patch('nova.console.websocketproxy.TenantSock.finish_up')
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token',
return_value=True)
def test_proxy_connect_err(self, check_token, mock_finish, mock_close):
check_token.return_value = {
'host': 'node1',
'port': '10000',
'console_type': 'novnc',
'access_url': 'https://example.net:6080'
}
ex = exception.SecurityProxyNegotiationFailed("Wibble")
self.server.security_proxy.connect.side_effect = ex
self.assertRaises(exception.SecurityProxyNegotiationFailed,
self.wh.new_websocket_client)
self.assertEqual(len(self.wh.do_proxy.calls), 0)
mock_close.assert_called_with()
self.assertEqual(len(mock_finish.calls), 0)