1
0
Fork 0
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:
catborise 2026-10-26 20:26:19 +03:00
parent 9c4c525800
commit 1a6dcc3e54
3 changed files with 54 additions and 201 deletions

View file

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

View file

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

View file

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