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:
parent
ae4b5d0147
commit
2a04b4dadf
@ -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()
|
||||
|
0
nova/console/securityproxy/__init__.py
Normal file
0
nova/console/securityproxy/__init__.py
Normal file
47
nova/console/securityproxy/base.py
Normal file
47
nova/console/securityproxy/base.py
Normal 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
|
@ -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
|
||||
|
@ -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.")
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user