From b2ddb14d5f44406c7b378f71e2b55abbaf898c6a Mon Sep 17 00:00:00 2001 From: Radoslav Gerganov Date: Fri, 22 Jul 2016 16:14:00 +0300 Subject: [PATCH] Initial commit --- .gitignore | 2 + .testr.conf | 4 + LICENSE | 176 ++++++++++++++++++++++++++++++++++ README.rst | 25 +++++ novaproxy/__init__.py | 0 novaproxy/authd.py | 125 ++++++++++++++++++++++++ novaproxy/mksproxy.py | 85 ++++++++++++++++ novaproxy/tests/__init__.py | 0 novaproxy/tests/test_authd.py | 93 ++++++++++++++++++ requirements.txt | 2 + setup.py | 25 +++++ test-requirements.txt | 4 + tox.ini | 14 +++ 13 files changed, 555 insertions(+) create mode 100644 .gitignore create mode 100644 .testr.conf create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 novaproxy/__init__.py create mode 100644 novaproxy/authd.py create mode 100644 novaproxy/mksproxy.py create mode 100644 novaproxy/tests/__init__.py create mode 100644 novaproxy/tests/test_authd.py create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 test-requirements.txt create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d04a2dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.py[cod] + diff --git a/.testr.conf b/.testr.conf new file mode 100644 index 0000000..7e689a3 --- /dev/null +++ b/.testr.conf @@ -0,0 +1,4 @@ +[DEFAULT] +test_command=python -m subunit.run discover -t ./ ./novaproxy/tests $LISTOPT $IDOPTION +test_id_option=--load-list $IDFILE +test_list_option=--list diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68c771a --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..357edd4 --- /dev/null +++ b/README.rst @@ -0,0 +1,25 @@ +nova-mksproxy +============= + +This is Nova console proxy for instances running on VMware. +It requires Nova to support microversion 2.31 to work. + +Usage +----- +1. Start the proxy with OpenStack admin credentials:: + + $ source openrc admin admin + $ nova-mksproxy --web /opt/share/noVNC + + by default it binds to ``0.0.0.0:6090`` + +2. Configure Nova to enable MKS consoles:: + + [mks] + enabled = True + mksproxy_base_url = http://:/vnc_auto.html + +3. Use nova CLI to get a console URL:: + + $ nova get-mks-console cirros + diff --git a/novaproxy/__init__.py b/novaproxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/novaproxy/authd.py b/novaproxy/authd.py new file mode 100644 index 0000000..2cf23e2 --- /dev/null +++ b/novaproxy/authd.py @@ -0,0 +1,125 @@ +# Copyright (c) 2016 VMware 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 base64 +import Cookie +import hashlib +import json +import os +import socket +import ssl +import urlparse + +import websockify + + +VMAD_OK = 200 +VMAD_WELCOME = 220 +VMAD_LOGINOK = 230 +VMAD_NEEDPASSWD = 331 +VMAD_USER_CMD = "USER" +VMAD_PASS_CMD = "PASS" +VMAD_THUMB_CMD = "THUMBPRINT" +VMAD_CONNECT_CMD = "CONNECT" + + +def expect(sock, code): + line = sock.recv(1024) + recv_code, msg = line.split()[0:2] + recv_code = int(recv_code) + if code != recv_code: + raise Exception('Expected %d but received %d' % (code, recv_code)) + return msg + + +def handshake(host, port, ticket, cfg_file, thumbprint): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + expect(sock, VMAD_WELCOME) + sock = ssl.wrap_socket(sock) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + cert = sock.getpeercert(binary_form=True) + h = hashlib.sha1() + h.update(cert) + if thumbprint != h.hexdigest(): + raise Exception("Server thumbprint doesn't match") + sock.write("%s %s\r\n" % (VMAD_USER_CMD, ticket)) + expect(sock, VMAD_NEEDPASSWD) + sock.write("%s %s\r\n" % (VMAD_PASS_CMD, ticket)) + expect(sock, VMAD_LOGINOK) + rand = os.urandom(12) + rand = base64.b64encode(rand) + sock.write("%s %s\r\n" % (VMAD_THUMB_CMD, rand)) + thumbprint2 = expect(sock, VMAD_OK) + thumbprint2 = thumbprint2.replace(':', '').lower() + sock.write("%s %s mks\r\n" % (VMAD_CONNECT_CMD, cfg_file)) + expect(sock, VMAD_OK) + sock2 = ssl.wrap_socket(sock) + cert2 = sock2.getpeercert(binary_form=True) + h = hashlib.sha1() + h.update(cert2) + if thumbprint2 != h.hexdigest(): + raise Exception("Second thumbprint doesn't match") + sock2.write(rand) + return sock2 + + +class AuthdRequestHandler(websockify.ProxyRequestHandler): + + @classmethod + def set_nova_client(cls, nova_client): + cls._nova_client = nova_client + + def new_websocket_client(self): + # The nova expected behavior is to have token + # passed to the method GET of the request + parse = urlparse.urlparse(self.path) + query = parse.query + token = urlparse.parse_qs(query).get("token", [""]).pop() + if not token: + # NoVNC uses it's own convention that forward token + # from the request to a cookie header, we should check + # also for this behavior + hcookie = self.headers.getheader('cookie') + if hcookie: + cookie = Cookie.SimpleCookie() + cookie.load(hcookie) + if 'token' in cookie: + token = cookie['token'].value + + resp, body = self._nova_client.client.get( + '/os-console-auth-tokens/%s' % token) + # TODO check response + connect_info = body['console'] + host = connect_info['host'] + port = connect_info['port'] + internal_access_path = connect_info['internal_access_path'] + mks_auth = json.loads(internal_access_path) + ticket = mks_auth['ticket'] + cfg_file = mks_auth['cfgFile'] + thumbprint = mks_auth['thumbprint'] + + tsock = handshake(host, port, ticket, cfg_file, thumbprint) + + # Start proxying + try: + self.do_proxy(tsock) + except Exception: + if tsock: + tsock.shutdown(socket.SHUT_RDWR) + tsock.close() + self.vmsg("%(host)s:%(port)s: Target closed" % + {'host': host, 'port': port}) + raise diff --git a/novaproxy/mksproxy.py b/novaproxy/mksproxy.py new file mode 100644 index 0000000..777b1ad --- /dev/null +++ b/novaproxy/mksproxy.py @@ -0,0 +1,85 @@ +# Copyright (c) 2016 VMware 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 os +import sys + +import argparse +import authd +from novaclient import client +import websockify +import logging + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", help="MKS proxy host (default '0.0.0.0')", + default="0.0.0.0") + parser.add_argument("--port", help="MKS proxy port (default 6090)", + type=int, default=6090) + parser.add_argument("--web", help="web location", required=True) + parser.add_argument("--verbose", help="verbose logging", + action="store_true", default=False) + parser.add_argument("--username", + help="OpenStack username (default $OS_USERNAME)", + default=os.environ.get("OS_USERNAME")) + parser.add_argument("--password", + help="OpenStack password (default $OS_PASSWORD)", + default=os.environ.get("OS_PASSWORD")) + parser.add_argument("--project", + help="OpenStack project (default $OS_PROJECT_NAME or " + "$OS_TENANT_NAME)", + default=os.environ.get("OS_PROJECT_NAME", + os.environ.get("OS_TENANT_NAME"))) + parser.add_argument("--auth-url", + help="OpenStack auth url (default $OS_AUTH_URL)", + default=os.environ.get("OS_AUTH_URL")) + # TODO add log-file + # TODO add cert/key + args = parser.parse_args() + + if not args.username: + sys.stderr.write('Missing OpenStack username\n') + sys.exit(1) + if not args.password: + sys.stderr.write('Missing OpenStack password\n') + sys.exit(1) + if not args.project: + sys.stderr.write('Missing OpenStack project\n') + sys.exit(1) + if not args.auth_url: + sys.stderr.write('Missing OpenStack auth URL\n') + sys.exit(1) + + websockify.websocketproxy.logger_init() + logger = logging.getLogger(websockify.WebSocketProxy.log_prefix) + if args.verbose: + logger.setLevel(logging.DEBUG) + + nova_client = client.Client("2.31", args.username, args.password, + args.project, args.auth_url, logger=logger) + + authd.AuthdRequestHandler.set_nova_client(nova_client) + + websockify.WebSocketProxy( + listen_host=args.host, + listen_port=args.port, + verbose=args.verbose, + web=args.web, + file_only=True, + RequestHandlerClass=authd.AuthdRequestHandler + ).start_server() + +if __name__ == '__main__': + main() diff --git a/novaproxy/tests/__init__.py b/novaproxy/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/novaproxy/tests/test_authd.py b/novaproxy/tests/test_authd.py new file mode 100644 index 0000000..1859b75 --- /dev/null +++ b/novaproxy/tests/test_authd.py @@ -0,0 +1,93 @@ +# Copyright (c) 2016 VMware 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 socket +import ssl + +import mock +import testtools + +from novaproxy import authd + + +class AuthdRequestHandler(testtools.TestCase): + + @mock.patch.object(socket, 'socket') + @mock.patch.object(ssl, 'wrap_socket') + def test_handshake(self, mock_wrap, mock_socket): + msgs = ['220 Welcome', + '331 Need password', + '230 Login OK', + '200 e6e4d191c0f9ebc6abc082d6e2eeeb5b7c90214d', + '200 OK'] + + def fake_recv(len): + return msgs.pop(0) + + def fake_getpeercert(binary_form=True): + return 'fake-certificate' + + sock = mock.MagicMock() + sock.recv = fake_recv + sock.getpeercert = fake_getpeercert + mock_wrap.return_value = sock + mock_socket.return_value = sock + authd.handshake('host', 902, 'ticket', 'cfgFile', + 'e6e4d191c0f9ebc6abc082d6e2eeeb5b7c90214d') + + @mock.patch.object(socket, 'socket') + @mock.patch.object(ssl, 'wrap_socket') + def test_handshake_invalid_thumbprint(self, mock_wrap, mock_socket): + msgs = ['220 Welcome', + '331 Need password', + '230 Login OK', + '200 e6e4d191c0f9ebc6abc082d6e2eeeb5b7c90214d', + '200 OK'] + + def fake_recv(len): + return msgs.pop(0) + + def fake_getpeercert(binary_form=True): + return 'fake-certificate' + + sock = mock.MagicMock() + sock.recv = fake_recv + sock.getpeercert = fake_getpeercert + mock_wrap.return_value = sock + mock_socket.return_value = sock + self.assertRaises(Exception, authd.handshake, 'host', 902, + 'ticket', 'cfgFile', 'invalid-thumbprint') + + @mock.patch.object(socket, 'socket') + @mock.patch.object(ssl, 'wrap_socket') + def test_handshake_invalid_2nd_thumbprint(self, mock_wrap, mock_socket): + msgs = ['220 Welcome', + '331 Need password', + '230 Login OK', + '200 invalid-2nd-thumbprint', + '200 OK'] + + def fake_recv(len): + return msgs.pop(0) + + def fake_getpeercert(binary_form=True): + return 'fake-certificate' + + sock = mock.MagicMock() + sock.recv = fake_recv + sock.getpeercert = fake_getpeercert + mock_wrap.return_value = sock + mock_socket.return_value = sock + self.assertRaises(Exception, authd.handshake, 'host', 902, + 'ticket', 'cfg', 'e6e4d191c0f9ebc6abc082d6e2eeeb5b7c90214d') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ea2e1a8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-novaclient +websockify diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..34f4f4b --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +from setuptools import setup + +with open('README.rst') as f: + readme = f.read() + + +with open('LICENSE') as f: + license = f.read() + + +setup( + name='nova-mksproxy', + version='0.0.1', + description='Nova console proxy for VMware instances', + long_description=readme, + author='VMware Inc.', + author_email='rgerganov@vmware.com', + url='https://github.com/rgerganov/nova-mksproxy', + license=license, + entry_points = { + 'console_scripts': ['nova-mksproxy=novaproxy.mksproxy:main'], + }, + packages=['novaproxy'], + install_requires=['websockify', 'python-novaclient'] +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..0112e5b --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,4 @@ +testtools +mock +testrepository +flake8 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..2951b48 --- /dev/null +++ b/tox.ini @@ -0,0 +1,14 @@ +[tox] +envlist = py27,pep8 +skipsdist = True + +[testenv] +deps = -rrequirements.txt + -rtest-requirements.txt +commands = python setup.py testr --slowest --testr-args='{posargs}' + +[testenv:pep8] +commands = flake8 + +[flake8] +ignore = E121,E122,E123,E124,E125,E126,E127,E128,E129,E131,E251,H405