mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
refactor(console): novncd keeps only the websockify handler it runs
- The fallbacks for websockify releases without ProxyRequestHandler and for noVNC's old wsproxy are gone (websockify 0.13 is pinned), with the Nova names, the per-connection mode switch and the traffic legend. - The certificate option is gone: nginx ends TLS and novncd speaks plain websockets on the loopback. - The mixin is ConsoleClient and the handler ConsoleProxyHandler; the CLI test that tested a copy of the parser instead of novncd's is removed (novncd's own parser is tested).
This commit is contained in:
parent
9c4c525800
commit
1a6dcc3e54
3 changed files with 54 additions and 201 deletions
157
console/novncd
157
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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue