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:
Hongbin Lu 2018-04-23 01:51:44 +00:00
parent 48075909a9
commit ed9e3ee72b
6 changed files with 130 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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