This branch adds web based serial console access. Here is an overview of how it works (for libvirt):
1. User requests an ajax console for an instance_id (either through OS api, or tools/euca-get-ajax-console)
a. api server calls compute worker to complete request
b. compute worker parses an instance's xml to locate its pseudo terminal (/dev/pts/x)
c. compute worker spawns an ajaxterm daemon, bound to a random port in a specified range. socat is used to connect to /dev/pts/x. Note that ajaxterm was modified in the following ways:
i. dies after 5 minutes of inactivity
ii. now requires token authentication. Previously it was trivial to hijack an ajaxterm
d. compute worker returns ajaxterm connect information to the api server: port, host, token
e. api server casts connect information to the nova-ajax-console-proxy (a new service)
f. api server returns a url for the ajaxterm (eg. http://nova-ajax-console-proxy/?token=123)
2. User now has a url, and can paste it in a browser
a. Browser sends request to https://nova-ajax-console-proxy/?token=123
b. nova-ajax-console-proxy maps token to connect information
c. nova-ajax-console-proxy constructs a proxy to the ajaxterm that is running on the host machine. This is now done with eventlet, though previously it was done using twisted
3. User interacts with console through web browser
NOTE: For this to work as expected, serial console login must be enabled in the instance. Instructions for how to do this on ubuntu can be found here: https://help.ubuntu.com/community/SerialConsoleHowto. Note that you must actively log out of the serial console when you are finished, otherwise the console will remain open even after the ajaxterm term session has ended.
Also note that nova.sh has been modified in this branch to launch nova-ajax-console-proxy.
This commit is contained in:
137
bin/nova-ajax-console-proxy
Executable file
137
bin/nova-ajax-console-proxy
Executable file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python
|
||||
# pylint: disable-msg=C0103
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2010 United States Government as represented by the
|
||||
# Administrator of the National Aeronautics and Space Administration.
|
||||
# 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.
|
||||
|
||||
"""Ajax Console Proxy Server"""
|
||||
|
||||
from eventlet import greenthread
|
||||
from eventlet.green import urllib2
|
||||
|
||||
import exceptions
|
||||
import gettext
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urlparse
|
||||
|
||||
# If ../nova/__init__.py exists, add ../ to Python search path, so that
|
||||
# it will override what happens to be installed in /usr/(local/)lib/python...
|
||||
possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
|
||||
os.pardir,
|
||||
os.pardir))
|
||||
if os.path.exists(os.path.join(possible_topdir, 'nova', '__init__.py')):
|
||||
sys.path.insert(0, possible_topdir)
|
||||
|
||||
gettext.install('nova', unicode=1)
|
||||
|
||||
from nova import flags
|
||||
from nova import log as logging
|
||||
from nova import rpc
|
||||
from nova import utils
|
||||
from nova import wsgi
|
||||
|
||||
FLAGS = flags.FLAGS
|
||||
|
||||
flags.DEFINE_integer('ajax_console_idle_timeout', 300,
|
||||
'Seconds before idle connection destroyed')
|
||||
|
||||
LOG = logging.getLogger('nova.ajax_console_proxy')
|
||||
LOG.setLevel(logging.DEBUG)
|
||||
LOG.addHandler(logging.StreamHandler())
|
||||
|
||||
|
||||
class AjaxConsoleProxy(object):
|
||||
tokens = {}
|
||||
|
||||
def __call__(self, env, start_response):
|
||||
try:
|
||||
req_url = '%s://%s%s?%s' % (env['wsgi.url_scheme'],
|
||||
env['HTTP_HOST'],
|
||||
env['PATH_INFO'],
|
||||
env['QUERY_STRING'])
|
||||
if 'HTTP_REFERER' in env:
|
||||
auth_url = env['HTTP_REFERER']
|
||||
else:
|
||||
auth_url = req_url
|
||||
|
||||
auth_params = urlparse.parse_qs(urlparse.urlparse(auth_url).query)
|
||||
parsed_url = urlparse.urlparse(req_url)
|
||||
|
||||
auth_info = AjaxConsoleProxy.tokens[auth_params['token'][0]]
|
||||
args = auth_info['args']
|
||||
auth_info['last_activity'] = time.time()
|
||||
|
||||
remote_url = ("http://%s:%s%s?token=%s" % (
|
||||
str(args['host']),
|
||||
str(args['port']),
|
||||
parsed_url.path,
|
||||
str(args['token'])))
|
||||
|
||||
opener = urllib2.urlopen(remote_url, env['wsgi.input'].read())
|
||||
body = opener.read()
|
||||
info = opener.info()
|
||||
|
||||
start_response("200 OK", info.dict.items())
|
||||
return body
|
||||
except (exceptions.KeyError):
|
||||
if env['PATH_INFO'] != '/favicon.ico':
|
||||
LOG.audit("Unauthorized request %s, %s"
|
||||
% (req_url, str(env)))
|
||||
start_response("401 NOT AUTHORIZED", [])
|
||||
return "Not Authorized"
|
||||
except Exception:
|
||||
start_response("500 ERROR", [])
|
||||
return "Server Error"
|
||||
|
||||
def register_listeners(self):
|
||||
class Callback:
|
||||
def __call__(self, data, message):
|
||||
if data['method'] == 'authorize_ajax_console':
|
||||
AjaxConsoleProxy.tokens[data['args']['token']] = \
|
||||
{'args': data['args'], 'last_activity': time.time()}
|
||||
|
||||
conn = rpc.Connection.instance(new=True)
|
||||
consumer = rpc.TopicConsumer(
|
||||
connection=conn,
|
||||
topic=FLAGS.ajax_console_proxy_topic)
|
||||
consumer.register_callback(Callback())
|
||||
|
||||
def delete_expired_tokens():
|
||||
now = time.time()
|
||||
to_delete = []
|
||||
for k, v in AjaxConsoleProxy.tokens.items():
|
||||
if now - v['last_activity'] > FLAGS.ajax_console_idle_timeout:
|
||||
to_delete.append(k)
|
||||
|
||||
for k in to_delete:
|
||||
del AjaxConsoleProxy.tokens[k]
|
||||
|
||||
utils.LoopingCall(consumer.fetch, auto_ack=True,
|
||||
enable_callbacks=True).start(0.1)
|
||||
utils.LoopingCall(delete_expired_tokens).start(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
utils.default_flagfile()
|
||||
FLAGS(sys.argv)
|
||||
server = wsgi.Server()
|
||||
acp = AjaxConsoleProxy()
|
||||
acp.register_listeners()
|
||||
server.start(acp, FLAGS.ajax_console_proxy_port, host='0.0.0.0')
|
||||
server.wait()
|
||||
@@ -234,7 +234,14 @@ DEFINE_string('scheduler_topic', 'scheduler',
|
||||
'the topic scheduler nodes listen on')
|
||||
DEFINE_string('volume_topic', 'volume', 'the topic volume nodes listen on')
|
||||
DEFINE_string('network_topic', 'network', 'the topic network nodes listen on')
|
||||
|
||||
DEFINE_string('ajax_console_proxy_topic', 'ajax_proxy',
|
||||
'the topic ajax proxy nodes listen on')
|
||||
DEFINE_string('ajax_console_proxy_url',
|
||||
'http://127.0.0.1:8000',
|
||||
'location of ajax console proxy, \
|
||||
in the form "http://127.0.0.1:8000"')
|
||||
DEFINE_string('ajax_console_proxy_port',
|
||||
8000, 'port that ajax_console_proxy binds')
|
||||
DEFINE_bool('verbose', False, 'show debug output')
|
||||
DEFINE_boolean('fake_rabbit', False, 'use a fake rabbit')
|
||||
DEFINE_bool('fake_network', False,
|
||||
|
||||
@@ -167,6 +167,19 @@ class CloudTestCase(test.TestCase):
|
||||
greenthread.sleep(0.3)
|
||||
rv = self.cloud.terminate_instances(self.context, [instance_id])
|
||||
|
||||
def test_ajax_console(self):
|
||||
kwargs = {'image_id': image_id}
|
||||
rv = yield self.cloud.run_instances(self.context, **kwargs)
|
||||
instance_id = rv['instancesSet'][0]['instanceId']
|
||||
output = yield self.cloud.get_console_output(context=self.context,
|
||||
instance_id=[instance_id])
|
||||
self.assertEquals(b64decode(output['output']),
|
||||
'http://fakeajaxconsole.com/?token=FAKETOKEN')
|
||||
# TODO(soren): We need this until we can stop polling in the rpc code
|
||||
# for unit tests.
|
||||
greenthread.sleep(0.3)
|
||||
rv = yield self.cloud.terminate_instances(self.context, [instance_id])
|
||||
|
||||
def test_key_generation(self):
|
||||
result = self._create_key('test')
|
||||
private_key = result['private_key']
|
||||
|
||||
@@ -169,6 +169,16 @@ class ComputeTestCase(test.TestCase):
|
||||
self.assert_(console)
|
||||
self.compute.terminate_instance(self.context, instance_id)
|
||||
|
||||
def test_ajax_console(self):
|
||||
"""Make sure we can get console output from instance"""
|
||||
instance_id = self._create_instance()
|
||||
self.compute.run_instance(self.context, instance_id)
|
||||
|
||||
console = self.compute.get_ajax_console(self.context,
|
||||
instance_id)
|
||||
self.assert_(console)
|
||||
self.compute.terminate_instance(self.context, instance_id)
|
||||
|
||||
def test_run_instance_existing(self):
|
||||
"""Ensure failure when running an instance that already exists"""
|
||||
instance_id = self._create_instance()
|
||||
|
||||
Reference in New Issue
Block a user