diff --git a/console/novncd b/console/novncd index a4271c86..750b41a1 100755 --- a/console/novncd +++ b/console/novncd @@ -1,31 +1,32 @@ #!/usr/bin/env python3 +# The VNC console proxy: nginx passes /novncd/ websockets to it; each one +# carries a console token and is joined to that VM's VNC server, directly, +# through an SSH tunnel or a local Unix socket. -import os -import sys +import argparse +import ipaddress import logging +import os +import socket +import sys +from urllib.parse import parse_qs, urlparse + import django -DIR_PATH = os.path.dirname(os.path.abspath(__file__)) -ROOT_PATH = os.path.abspath(os.path.join(DIR_PATH, "..", "")) -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webvirtcloud.settings") -CERT = DIR_PATH + "/cert.pem" - +ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) if ROOT_PATH not in sys.path: sys.path.append(ROOT_PATH) - +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webvirtcloud.settings") django.setup() -import ipaddress -import socket +from websockify import ProxyRequestHandler, WebSocketProxy # noqa: E402 -from urllib.parse import parse_qs, urlparse -import argparse -from console.sshtunnels import close_tunnel, open_tunnel, tunnel_target -from vrtManager.connection import CONN_SOCKET, CONN_SSH -from webvirtcloud.settings import WS_HOST, WS_PORT +from console.sshtunnels import close_tunnel, open_tunnel, tunnel_target # noqa: E402 +from vrtManager.connection import CONN_SOCKET, CONN_SSH # noqa: E402 +from webvirtcloud.settings import WS_HOST, WS_PORT # noqa: E402 -def get_parser(default_host=WS_HOST, default_port=WS_PORT or 6080, default_cert=CERT): +def get_parser(default_host=WS_HOST, default_port=WS_PORT or 6080): parser = argparse.ArgumentParser(description="WebVirtCloud noVNC daemon") parser.add_argument( "-v", @@ -50,37 +51,9 @@ def get_parser(default_host=WS_HOST, default_port=WS_PORT or 6080, default_cert= help="Listen port", default=default_port, ) - parser.add_argument( - "-c", - "--cert", - dest="cert", - action="store", - help="Certificate file path", - default=default_cert, - ) return parser - -try: - from websockify import WebSocketProxy - - try: - from websockify import ProxyRequestHandler - except ImportError: - USE_HANDLER = False - else: - USE_HANDLER = True -except ImportError: - try: - from novnc.wsproxy import WebSocketProxy - except ImportError: - print("Unable to import a websockify implementation,\n please install one") - sys.exit(1) - else: - USE_HANDLER = False - - def get_connection_infos(token): from console.tokens import authorize from vrtManager.instance import wvmInstance @@ -116,8 +89,11 @@ def is_loopback(address): return address == "localhost" -class CompatibilityMixIn(object): - def _new_client(self, daemon, socket_factory): +class ConsoleClient: + """What a console websocket is joined to; ConsoleProxyHandler is the + websockify handler that runs it.""" + + def _new_client(self, socket_factory): # The page puts its own console's token on its websocket URL: a cookie # is shared by the browser's tabs and would name the last console opened token = parse_qs(urlparse(self.path).query).get("token", [None])[0] @@ -162,9 +138,6 @@ class CompatibilityMixIn(object): self.msg("connecting to: %s:%s" % (address, console_port)) tsock = socket_factory(address, console_port, connect=True) - if self.verbose and not daemon: - print(self.traffic_legend) - try: self.msg("Start proxying") self.do_proxy(tsock) @@ -176,38 +149,12 @@ class CompatibilityMixIn(object): logging.warning("SSH tunnel to %s@%s failed: %s", connuser, connhost, error) -if USE_HANDLER: +class ConsoleProxyHandler(ProxyRequestHandler, ConsoleClient): + def msg(self, *args, **kwargs): + self.log_message(*args, **kwargs) - class NovaProxyRequestHandler(ProxyRequestHandler, CompatibilityMixIn): - def msg(self, *args, **kwargs): - self.log_message(*args, **kwargs) - - def vmsg(self, *args, **kwargs): - if self.verbose: - self.msg(*args, **kwargs) - - def new_websocket_client(self): - """ - Called after a new WebSocket connection has been established. - """ - # Setup variable for compatibility - daemon = self.server.daemon - socket_factory = self.server.socket - - self._new_client(daemon, socket_factory) - -else: - - class NovaWebSocketProxy(WebSocketProxy, CompatibilityMixIn): - def new_client(self): - """ - Called after a new WebSocket connection has been established. - """ - # Setup variable for compatibility - daemon = self.daemon - socket_factory = self.socket - - self._new_client(daemon, socket_factory) + def new_websocket_client(self): + self._new_client(self.server.socket) if __name__ == "__main__": @@ -222,42 +169,14 @@ if __name__ == "__main__": else: logging.basicConfig(level=logging.WARNING, format=FORMAT) - if USE_HANDLER: - # Create the WebSocketProxy with NovaProxyRequestHandler handler - server = WebSocketProxy( - RequestHandlerClass=NovaProxyRequestHandler, - listen_host=options.host, - listen_port=options.port, - source_is_ipv6=False, - verbose=options.verbose, - cert=options.cert, - key=None, - ssl_only=False, - daemon=False, - record=False, - web=False, - traffic=False, - target_host="ignore", - target_port="ignore", - wrap_mode="exit", - wrap_cmd=None, - ) - else: - # Create the NovaWebSockets proxy - server = NovaWebSocketProxy( - listen_host=options.host, - listen_port=options.port, - source_is_ipv6=False, - verbose=options.verbose, - cert=options.cert, - key=None, - ssl_only=False, - daemon=False, - record=False, - web=False, - target_host="ignore", - target_port="ignore", - wrap_mode="exit", - wrap_cmd=None, - ) - server.start_server() + # nginx ends TLS: novncd speaks plain websockets on the loopback + WebSocketProxy( + RequestHandlerClass=ConsoleProxyHandler, + listen_host=options.host, + listen_port=options.port, + verbose=options.verbose, + daemon=False, + web=False, + target_host="ignore", + target_port="ignore", + ).start_server() diff --git a/console/tests/test_cli_parsers.py b/console/tests/test_cli_parsers.py deleted file mode 100644 index 23424acb..00000000 --- a/console/tests/test_cli_parsers.py +++ /dev/null @@ -1,57 +0,0 @@ -import argparse -import unittest - - -class TestCLIParsers(unittest.TestCase): - def test_novncd_parser_defaults(self): - # We test the parser specification without importing django - parser = argparse.ArgumentParser(description="WebVirtCloud noVNC daemon") - parser.add_argument("-v", "--verbose", dest="verbose", action="store_true", default=False) - parser.add_argument("-d", "--debug", dest="debug", action="store_true", default=False) - parser.add_argument("-H", "--host", dest="host", action="store", default="0.0.0.0") - parser.add_argument("-p", "--port", dest="port", action="store", type=int, default=6080) - parser.add_argument("-c", "--cert", dest="cert", action="store", default="cert.pem") - - opts = parser.parse_args([]) - self.assertFalse(opts.verbose) - self.assertFalse(opts.debug) - self.assertEqual(opts.host, "0.0.0.0") - self.assertEqual(opts.port, 6080) - self.assertEqual(opts.cert, "cert.pem") - - def test_novncd_parser_custom_args(self): - parser = argparse.ArgumentParser(description="WebVirtCloud noVNC daemon") - parser.add_argument("-v", "--verbose", dest="verbose", action="store_true", default=False) - parser.add_argument("-d", "--debug", dest="debug", action="store_true", default=False) - parser.add_argument("-H", "--host", dest="host", action="store", default="0.0.0.0") - parser.add_argument("-p", "--port", dest="port", action="store", type=int, default=6080) - parser.add_argument("-c", "--cert", dest="cert", action="store", default="cert.pem") - - opts = parser.parse_args(["-v", "-d", "-H", "192.168.1.50", "-p", "7000", "-c", "/etc/ssl/cert.pem"]) - self.assertTrue(opts.verbose) - self.assertTrue(opts.debug) - self.assertEqual(opts.host, "192.168.1.50") - self.assertEqual(opts.port, 7000) - self.assertEqual(opts.cert, "/etc/ssl/cert.pem") - - def test_socketiod_parser_defaults_and_custom(self): - parser = argparse.ArgumentParser(description="WebVirtCloud Socket.IO daemon") - parser.add_argument("-v", "--verbose", dest="verbose", action="store_true", default=False) - parser.add_argument("-d", "--debug", dest="debug", action="store_true", default=False) - parser.add_argument("-H", "--host", dest="host", action="store", default="localhost") - parser.add_argument("-p", "--port", dest="port", action="store", type=int, default=6081) - - opts_def = parser.parse_args([]) - self.assertFalse(opts_def.verbose) - self.assertFalse(opts_def.debug) - self.assertEqual(opts_def.host, "localhost") - self.assertEqual(opts_def.port, 6081) - - opts_custom = parser.parse_args(["--verbose", "--host", "10.0.0.1", "--port", "9000"]) - self.assertTrue(opts_custom.verbose) - self.assertEqual(opts_custom.host, "10.0.0.1") - self.assertEqual(opts_custom.port, 9000) - - -if __name__ == "__main__": - unittest.main() diff --git a/console/tests/test_views.py b/console/tests/test_views.py index 801674b1..0f67a6f0 100644 --- a/console/tests/test_views.py +++ b/console/tests/test_views.py @@ -449,7 +449,7 @@ class ConsoleViewsTestCase(TestCase): class NovncdDaemonLogicTestCase(TestCase): def setUp(self): self.get_parser = novncd_mod.get_parser - self.CompatibilityMixIn = novncd_mod.CompatibilityMixIn + self.ConsoleClient = novncd_mod.ConsoleClient def test_novncd_parser_defaults(self): parser = self.get_parser() @@ -460,26 +460,22 @@ class NovncdDaemonLogicTestCase(TestCase): def test_novncd_parser_custom_arguments(self): parser = self.get_parser() - opts = parser.parse_args( - ["-v", "-d", "-H", "192.168.1.100", "-p", "7070", "-c", "/path/to/cert.pem"] - ) + opts = parser.parse_args(["-v", "-d", "-H", "192.168.1.100", "-p", "7070"]) self.assertTrue(opts.verbose) self.assertTrue(opts.debug) self.assertEqual(opts.host, "192.168.1.100") self.assertEqual(opts.port, 7070) - self.assertEqual(opts.cert, "/path/to/cert.pem") @patch.object(novncd_mod, "get_connection_infos") def test_a_cookie_does_not_choose_the_console(self, mock_get_info): # another tab's console in a cookie: the URL's own token is used mock_get_info.return_value = ("host1", "root", 1, "192.168.1.50", 5900, None) - handler = self.CompatibilityMixIn() + handler = self.ConsoleClient() handler.headers = {"cookie": "token=1-uuid-of-another-tab"} handler.path = "/novncd/?token=1-uuid-of-this-tab" handler.msg = MagicMock() handler.do_proxy = MagicMock() - handler.verbose = False - handler._new_client(daemon=False, socket_factory=MagicMock()) + handler._new_client(socket_factory=MagicMock()) mock_get_info.assert_called_once_with("1-uuid-of-this-tab") @patch.object(novncd_mod, "get_connection_infos") @@ -493,15 +489,14 @@ class NovncdDaemonLogicTestCase(TestCase): None, ) - handler = self.CompatibilityMixIn() + handler = self.ConsoleClient() handler.headers = {} handler.path = "/novncd/?token=1-uuid-from-query" handler.msg = MagicMock() handler.do_proxy = MagicMock() - handler.verbose = False socket_factory = MagicMock() - handler._new_client(daemon=False, socket_factory=socket_factory) + handler._new_client(socket_factory=socket_factory) mock_get_info.assert_called_once_with("1-uuid-from-query") # the address the console listens on @@ -511,14 +506,13 @@ class NovncdDaemonLogicTestCase(TestCase): def test_a_local_socket_host_reaches_a_console_on_its_loopback(self, mock_get_info): # libvirt on the panel's host, the VM's console listening on 127.0.0.1 mock_get_info.return_value = ("localhost", "", novncd_mod.CONN_SOCKET, "127.0.0.1", 5902, None) - handler = self.CompatibilityMixIn() + handler = self.ConsoleClient() handler.headers = {} handler.path = "/novncd/?token=1-uuid" handler.msg = MagicMock() handler.do_proxy = MagicMock() - handler.verbose = False socket_factory = MagicMock() - handler._new_client(daemon=False, socket_factory=socket_factory) + handler._new_client(socket_factory=socket_factory) socket_factory.assert_called_once_with("127.0.0.1", 5902, connect=True) def test_an_ssh_hosts_console_always_goes_through_the_tunnel(self): @@ -535,13 +529,12 @@ class NovncdDaemonLogicTestCase(TestCase): return_value=("kvm1.example.com", "root", novncd_mod.CONN_SSH, console_host, 5900, console_socket), ), patch.object(novncd_mod, "open_tunnel", return_value=(MagicMock(), MagicMock())) as open_tunnel, \ patch.object(novncd_mod, "close_tunnel", return_value="") as close_tunnel: - handler = self.CompatibilityMixIn() + handler = self.ConsoleClient() handler.path = "/novncd/?token=1-uuid" handler.msg = MagicMock() handler.do_proxy = MagicMock() - handler.verbose = False socket_factory = MagicMock() - handler._new_client(daemon=False, socket_factory=socket_factory) + handler._new_client(socket_factory=socket_factory) open_tunnel.assert_called_once_with("kvm1.example.com", "root", target) socket_factory.assert_not_called() # the tunnel ends with the session @@ -555,13 +548,12 @@ class NovncdDaemonLogicTestCase(TestCase): return_value=("kvm1.example.com", "", 1, console_host, 5900, None), ): - handler = self.CompatibilityMixIn() + handler = self.ConsoleClient() handler.path = "/novncd/?token=1-uuid" handler.msg = MagicMock() handler.do_proxy = MagicMock() - handler.verbose = False socket_factory = MagicMock() - handler._new_client(daemon=False, socket_factory=socket_factory) + handler._new_client(socket_factory=socket_factory) socket_factory.assert_called_once_with(reached, 5900, connect=True) def test_a_tcp_hosts_loopback_console_is_refused(self): @@ -569,25 +561,24 @@ class NovncdDaemonLogicTestCase(TestCase): with self.subTest(console_host=console_host), patch.object( novncd_mod, "get_connection_infos", return_value=("kvm1.example.com", "", 1, console_host, 5900, None), ): - handler = self.CompatibilityMixIn() + handler = self.ConsoleClient() handler.path = "/novncd/?token=1-uuid" handler.msg = MagicMock() - handler.verbose = False socket_factory = MagicMock() with self.assertRaises(Exception): - handler._new_client(daemon=False, socket_factory=socket_factory) + handler._new_client(socket_factory=socket_factory) socket_factory.assert_not_called() @patch.object(novncd_mod, "get_connection_infos") def test_compatibility_mixin_missing_token_safe_exit(self, mock_get_info): - handler = self.CompatibilityMixIn() + handler = self.ConsoleClient() handler.headers = {} handler.path = "/novncd/" handler.msg = MagicMock() socket_factory = MagicMock() # Should cleanly exit and log message without raising UnboundLocalError - handler._new_client(daemon=False, socket_factory=socket_factory) + handler._new_client(socket_factory=socket_factory) mock_get_info.assert_not_called() socket_factory.assert_not_called()