1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

fix(console): novncd reaches a VNC console where it listens

- A console listening on a given address is reached on that address;
  the compute's host name is used only for a wildcard listener (0.0.0.0,
  ::, none). It was always the host name, which may resolve to another
  interface.
- ::1 and localhost count as loopback like 127.0.0.0/8: such a console
  on a TCP or TLS host is refused instead of being tried on the host's
  name.
- The console page says why such a console cannot open and what to change
  (listen on all interfaces, or add the host over SSH) instead of a
  generic connection error.
This commit is contained in:
catborise 2026-10-26 19:58:42 +03:00
parent 68b104dfe4
commit 9c4c525800
3 changed files with 73 additions and 8 deletions

View file

@ -15,7 +15,7 @@ if ROOT_PATH not in sys.path:
django.setup()
import re
import ipaddress
import socket
from urllib.parse import parse_qs, urlparse
@ -105,6 +105,17 @@ def get_connection_infos(token):
return compute.hostname, compute.login, compute.type, console_host, console_port, console_socket
WILDCARD = (None, "", "0.0.0.0", "::")
def is_loopback(address):
"""127.0.0.0/8, ::1 or localhost: reachable only on the compute itself."""
try:
return ipaddress.ip_address(address).is_loopback
except ValueError:
return address == "localhost"
class CompatibilityMixIn(object):
def _new_client(self, daemon, socket_factory):
# The page puts its own console's token on its websocket URL: a cookie
@ -125,7 +136,7 @@ class CompatibilityMixIn(object):
)
tunnel = None
loopback = re.match(r"^127\.", console_host or "")
loopback = is_loopback(console_host)
if conntype == CONN_SSH:
# the console travels inside the SSH connection, encrypted,
# whatever address it listens on
@ -146,9 +157,10 @@ class CompatibilityMixIn(object):
self.msg(message)
raise Exception(message)
else:
# Direct access
self.msg("connecting to: %s:%s" % (connhost, console_port))
tsock = socket_factory(connhost, console_port, connect=True)
# the address the console listens on, or the compute's for a wildcard
address = connhost if console_host in WILDCARD else console_host
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)

View file

@ -48,12 +48,13 @@ class ConsoleViewsTestCase(TestCase):
username="bob", email="bob@example.com", password="bobpassword"
)
# SSH: novncd reaches any console listener (a TCP host's loopback one it cannot)
cls.compute = Compute.objects.create(
name="test-compute",
hostname="127.0.0.1",
login="root",
password="",
type=1,
type=2,
)
cls.instance = Instance.objects.create(
@ -275,6 +276,15 @@ class ConsoleViewsTestCase(TestCase):
self.assertEqual(response.status_code, 403)
self.assertTemplateNotUsed(response, "console-vnc-lite.html")
@patch("console.views.wvmInstance")
def test_a_tcp_hosts_loopback_console_says_why_it_cannot_open(self, mock_wvm):
Compute.objects.filter(pk=self.compute.pk).update(type=1)
mock_wvm.return_value.get_console_type.return_value = "vnc"
mock_wvm.return_value.console_listens_locally.return_value = True
self.client.force_login(self.admin_user)
response = self.client.get(reverse("console") + f"?token={self.token}")
self.assertContains(response, "This console listens only on its host")
@patch("console.views.wvmInstance")
def test_both_pages_connect_to_novncd_behind_the_panels_own_address(self, mock_wvm):
# nginx proxies /novncd/ on the panel's own scheme, host and port: no
@ -494,7 +504,8 @@ class NovncdDaemonLogicTestCase(TestCase):
handler._new_client(daemon=False, socket_factory=socket_factory)
mock_get_info.assert_called_once_with("1-uuid-from-query")
socket_factory.assert_called_once_with("host2", 5901, connect=True)
# the address the console listens on
socket_factory.assert_called_once_with("192.168.1.50", 5901, connect=True)
@patch.object(novncd_mod, "get_connection_infos")
def test_a_local_socket_host_reaches_a_console_on_its_loopback(self, mock_get_info):
@ -536,6 +547,37 @@ class NovncdDaemonLogicTestCase(TestCase):
# the tunnel ends with the session
close_tunnel.assert_called_once_with(open_tunnel.return_value[1])
def test_a_tcp_hosts_console_is_reached_where_it_listens(self):
for console_host, reached in (("0.0.0.0", "kvm1.example.com"), ("::", "kvm1.example.com"),
(None, "kvm1.example.com"), ("192.0.2.40", "192.0.2.40")):
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.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)
socket_factory.assert_called_once_with(reached, 5900, connect=True)
def test_a_tcp_hosts_loopback_console_is_refused(self):
for console_host in ("127.0.0.1", "::1", "localhost"):
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.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)
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()

View file

@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
from instances.models import Instance
from instances.utils import can_open_console
from libvirt import libvirtError
from vrtManager.connection import CONN_TCP, CONN_TLS
from vrtManager.instance import wvmInstance
@ -105,8 +106,11 @@ def console(request):
console_websocket_port = conn.get_console_websocket_port()
console_passwd = conn.get_console_passwd()
running = conn.instance.isActive()
# novncd reaches a console that listens on its host's loopback only
# through SSH (or on this host)
unreachable = instance.compute.type in (CONN_TCP, CONN_TLS) and conn.console_listens_locally()
except (libvirtError, ValueError, IndexError, Instance.DoesNotExist):
running = False
running = unreachable = False
console_type = None
console_websocket_port = None
console_passwd = None
@ -114,6 +118,13 @@ def console(request):
if console_type and not running: # a shut-off VM has no console to connect to
console_error = _("The VM is not running: start it to open its console.")
response = render(request, "console-vnc-lite.html", locals())
elif console_type == "vnc" and unreachable:
console_error = _(
"This console listens only on its host, which the panel reaches over TCP or TLS. "
"Let the console listen on all interfaces in the VM's console settings, "
"or add the host as an SSH host."
)
response = render(request, "console-vnc-lite.html", locals())
elif console_type == "vnc":
console_page = "console-vnc-" + view_type + ".html"
response = render(request, console_page, locals())