Support websocket proxy for exec
In before, interactive exec works as following: * Client makes an API call to server to request an interactive execution inside a container * Server returns a URL that is an endpoint of the docker daemon * Client connects to the docker's URL This approach is considered to be unsecure because it directly exposes docker API endpoint to end-users. This patch changes the workflow to mitigate the security risk. The new workflow is as following: * Client makes an API call to server to request an interactive execution inside a container * Server return a URL that is the websocket proxy server * Clients connects to the proxy server * The proxy server proxies the request to docker daemon The proxy server will validate each incoming requests before doing the proxy calls. The API endpoint of docker daemon will be hidden from end-users. Change-Id: I68e49b99eee9e6c22a9df2cc19a1d2ba5053489e Partial-Bug: #1735076
This commit is contained in:
parent
48075909a9
commit
ed9e3ee72b
|
@ -133,7 +133,15 @@ class API(object):
|
|||
timestamps, tail, since)
|
||||
|
||||
def container_exec(self, context, container, *args):
|
||||
return self.rpcapi.container_exec(context, container, *args)
|
||||
data = self.rpcapi.container_exec(context, container, *args)
|
||||
token = data.pop('token', None)
|
||||
exec_id = data.get('exec_id')
|
||||
if token:
|
||||
data['proxy_url'] = '%s?token=%s&uuid=%s&exec_id=%s' % (
|
||||
CONF.websocket_proxy.base_url, token, container.uuid, exec_id)
|
||||
else:
|
||||
data['proxy_url'] = None
|
||||
return data
|
||||
|
||||
def container_exec_resize(self, context, container, *args):
|
||||
return self.rpcapi.container_exec_resize(context, container, *args)
|
||||
|
|
|
@ -730,12 +730,26 @@ class Manager(periodic_task.PeriodicTasks):
|
|||
exec_id = self.driver.execute_create(context, container, command,
|
||||
interactive)
|
||||
if run:
|
||||
return self.driver.execute_run(exec_id, command)
|
||||
output, exit_code = self.driver.execute_run(exec_id, command)
|
||||
# TODO(hongbin): remove url once bug #1735076 is fixed
|
||||
return {"output": output,
|
||||
"exit_code": exit_code,
|
||||
"exec_id": None,
|
||||
"url": None,
|
||||
"token": None}
|
||||
else:
|
||||
token = uuidutils.generate_uuid()
|
||||
url = CONF.docker.docker_remote_api_url
|
||||
exec_instace = objects.ExecInstance(
|
||||
context, container_id=container.id, exec_id=exec_id,
|
||||
url=url, token=token)
|
||||
exec_instace.create(context)
|
||||
# TODO(hongbin): remove url once bug #1735076 is fixed
|
||||
return {'output': None,
|
||||
'exit_code': None,
|
||||
'exec_id': exec_id,
|
||||
'url': CONF.docker.docker_remote_api_url}
|
||||
'url': url,
|
||||
'token': token}
|
||||
except exception.DockerError as e:
|
||||
LOG.error("Error occurred while calling Docker exec API: %s",
|
||||
six.text_type(e))
|
||||
|
|
|
@ -767,10 +767,7 @@ class DockerDriver(driver.ContainerDriver):
|
|||
raise exception.Conflict(_(
|
||||
"Timeout on executing command: %s") % command)
|
||||
inspect_res = docker.exec_inspect(exec_id)
|
||||
return {"output": output,
|
||||
"exit_code": inspect_res['ExitCode'],
|
||||
"exec_id": None,
|
||||
"url": None}
|
||||
return output, inspect_res['ExitCode']
|
||||
|
||||
def execute_resize(self, exec_id, height, width):
|
||||
height = int(height)
|
||||
|
|
|
@ -237,6 +237,28 @@ class TestAPI(base.TestCase):
|
|||
container=container, command="/bin/bash",
|
||||
run=True, interactive=True)
|
||||
|
||||
@mock.patch('zun.compute.rpcapi.API._call')
|
||||
@mock.patch('zun.api.servicegroup.ServiceGroup.service_is_up')
|
||||
@mock.patch('zun.objects.ZunService.list_by_binary')
|
||||
def test_container_exec_interactive(
|
||||
self, mock_srv_list, mock_srv_up, mock_call):
|
||||
mock_call.return_value = {'token': 'fake-token',
|
||||
'exec_id': 'fake-exec-id'}
|
||||
container = self.container
|
||||
srv = objects.ZunService(
|
||||
self.context,
|
||||
**utils.get_test_zun_service(host=container.host))
|
||||
mock_srv_list.return_value = [srv]
|
||||
mock_srv_up.return_value = True
|
||||
result = self.compute_api.container_exec(
|
||||
self.context, container, "/bin/bash", True, True)
|
||||
self.assertIn('fake-token', result['proxy_url'])
|
||||
self.assertIn('fake-exec-id', result['proxy_url'])
|
||||
mock_call.assert_called_once_with(
|
||||
container.host, "container_exec",
|
||||
container=container, command="/bin/bash",
|
||||
run=True, interactive=True)
|
||||
|
||||
@mock.patch('zun.compute.rpcapi.API._call')
|
||||
@mock.patch('zun.api.servicegroup.ServiceGroup.service_is_up')
|
||||
@mock.patch('zun.objects.ZunService.list_by_binary')
|
||||
|
|
|
@ -23,6 +23,7 @@ from zun.compute import manager
|
|||
import zun.conf
|
||||
from zun.objects.container import Container
|
||||
from zun.objects.container_action import ContainerActionEvent
|
||||
from zun.objects.exec_instance import ExecInstance
|
||||
from zun.objects.image import Image
|
||||
from zun.objects.network import Network
|
||||
from zun.objects.volume_mapping import VolumeMapping
|
||||
|
@ -973,13 +974,35 @@ class TestManager(base.TestCase):
|
|||
@mock.patch.object(fake_driver, 'execute_create')
|
||||
def test_container_execute(self, mock_execute_create, mock_execute_run):
|
||||
mock_execute_create.return_value = 'fake_exec_id'
|
||||
mock_execute_run.return_value = 'fake_output', 'fake_exit_code'
|
||||
container = Container(self.context, **utils.get_test_container())
|
||||
self.compute_manager.container_exec(
|
||||
result = self.compute_manager.container_exec(
|
||||
self.context, container, 'fake_cmd', True, False)
|
||||
self.assertEqual('fake_output', result.get('output'))
|
||||
self.assertEqual('fake_exit_code', result.get('exit_code'))
|
||||
self.assertIsNone(result.get('exec_id'))
|
||||
self.assertIsNone(result.get('token'))
|
||||
mock_execute_create.assert_called_once_with(
|
||||
self.context, container, 'fake_cmd', False)
|
||||
mock_execute_run.assert_called_once_with('fake_exec_id', 'fake_cmd')
|
||||
|
||||
@mock.patch.object(ExecInstance, 'create')
|
||||
@mock.patch.object(fake_driver, 'execute_run')
|
||||
@mock.patch.object(fake_driver, 'execute_create')
|
||||
def test_container_execute_interactive(
|
||||
self, mock_execute_create, mock_execute_run, mock_create):
|
||||
mock_execute_create.return_value = 'fake_exec_id'
|
||||
container = Container(self.context, **utils.get_test_container())
|
||||
result = self.compute_manager.container_exec(
|
||||
self.context, container, 'fake_cmd', False, True)
|
||||
self.assertIsNone(result.get('output'))
|
||||
self.assertIsNone(result.get('exit_code'))
|
||||
self.assertEqual('fake_exec_id', result.get('exec_id'))
|
||||
self.assertIsNotNone(result.get('token'))
|
||||
mock_execute_create.assert_called_once_with(
|
||||
self.context, container, 'fake_cmd', True)
|
||||
mock_execute_run.assert_not_called()
|
||||
|
||||
@mock.patch.object(fake_driver, 'execute_create')
|
||||
def test_container_execute_failed(self, mock_execute_create):
|
||||
container = Container(self.context, **utils.get_test_container())
|
||||
|
|
|
@ -23,11 +23,12 @@ import socket
|
|||
import sys
|
||||
import time
|
||||
|
||||
|
||||
import docker
|
||||
from oslo_log import log as logging
|
||||
from oslo_utils import uuidutils
|
||||
import six.moves.urllib.parse as urlparse
|
||||
import websockify
|
||||
|
||||
from zun.common import context
|
||||
from zun.common import exception
|
||||
from zun.common.i18n import _
|
||||
|
@ -121,7 +122,7 @@ class ZunProxyRequestHandlerBase(object):
|
|||
raise self.CClose(1000, "Target closed")
|
||||
self.cqueue.append(buf)
|
||||
|
||||
def do_proxy(self, target):
|
||||
def do_websocket_proxy(self, target):
|
||||
"""Proxy websocket link
|
||||
|
||||
Proxy client WebSocket to normal target socket.
|
||||
|
@ -189,6 +190,7 @@ class ZunProxyRequestHandlerBase(object):
|
|||
query = parse.query
|
||||
token = urlparse.parse_qs(query).get("token", [""]).pop()
|
||||
uuid = urlparse.parse_qs(query).get("uuid", [""]).pop()
|
||||
exec_id = urlparse.parse_qs(query).get("exec_id", [""]).pop()
|
||||
|
||||
ctx = context.get_admin_context(all_projects=True)
|
||||
|
||||
|
@ -197,12 +199,66 @@ class ZunProxyRequestHandlerBase(object):
|
|||
else:
|
||||
container = objects.Container.get_by_name(ctx, uuid)
|
||||
|
||||
if exec_id:
|
||||
self._new_exec_client(container, token, uuid, exec_id)
|
||||
else:
|
||||
self._new_websocket_client(container, token, uuid)
|
||||
|
||||
def _new_websocket_client(self, container, token, uuid):
|
||||
if token != container.websocket_token:
|
||||
raise exception.InvalidWebsocketToken(token)
|
||||
|
||||
access_url = '%s?token=%s&uuid=%s' % (CONF.websocket_proxy.base_url,
|
||||
token, uuid)
|
||||
|
||||
self._verify_origin(access_url)
|
||||
|
||||
if container.websocket_url:
|
||||
target_url = container.websocket_url
|
||||
escape = "~"
|
||||
close_wait = 0.5
|
||||
wscls = WebSocketClient(host_url=target_url, escape=escape,
|
||||
close_wait=close_wait)
|
||||
wscls.connect()
|
||||
self.target = wscls
|
||||
else:
|
||||
raise exception.InvalidWebsocketUrl()
|
||||
|
||||
# Start proxying
|
||||
try:
|
||||
self.do_websocket_proxy(self.target.ws)
|
||||
except Exception:
|
||||
if self.target.ws:
|
||||
self.target.ws.close()
|
||||
self.vmsg(_("Websocket client or target closed"))
|
||||
raise
|
||||
|
||||
def _new_exec_client(self, container, token, uuid, exec_id):
|
||||
exec_instance = None
|
||||
for e in container.exec_instances:
|
||||
if token == e.token and exec_id == e.exec_id:
|
||||
exec_instance = e
|
||||
|
||||
if not exec_instance:
|
||||
raise exception.InvalidWebsocketToken(token)
|
||||
|
||||
access_url = '%s?token=%s&uuid=%s' % (CONF.websocket_proxy.base_url,
|
||||
token, uuid)
|
||||
|
||||
self._verify_origin(access_url)
|
||||
|
||||
client = docker.APIClient(base_url=exec_instance.url)
|
||||
tsock = client.exec_start(exec_id, socket=True, tty=True)
|
||||
|
||||
try:
|
||||
self.do_proxy(tsock)
|
||||
finally:
|
||||
if tsock:
|
||||
tsock.shutdown(socket.SHUT_RDWR)
|
||||
tsock.close()
|
||||
self.vmsg(_("%s: Closed target") % exec_instance.url)
|
||||
|
||||
def _verify_origin(self, access_url):
|
||||
# Verify Origin
|
||||
expected_origin_hostname = self.headers.get('Host')
|
||||
if ':' in expected_origin_hostname:
|
||||
|
@ -230,26 +286,6 @@ class ZunProxyRequestHandlerBase(object):
|
|||
detail = _("Origin header protocol does not match this host.")
|
||||
raise exception.ValidationError(detail)
|
||||
|
||||
if container.websocket_url:
|
||||
target_url = container.websocket_url
|
||||
escape = "~"
|
||||
close_wait = 0.5
|
||||
wscls = WebSocketClient(host_url=target_url, escape=escape,
|
||||
close_wait=close_wait)
|
||||
wscls.connect()
|
||||
self.target = wscls
|
||||
else:
|
||||
raise exception.InvalidWebsocketUrl()
|
||||
|
||||
# Start proxying
|
||||
try:
|
||||
self.do_proxy(self.target.ws)
|
||||
except Exception as e:
|
||||
if self.target.ws:
|
||||
self.target.ws.close()
|
||||
self.vmsg(_("%Websocket client or target closed"))
|
||||
raise
|
||||
|
||||
|
||||
class ZunProxyRequestHandler(ZunProxyRequestHandlerBase,
|
||||
websockify.ProxyRequestHandler):
|
||||
|
|
Loading…
Reference in New Issue