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

fix(console): an SSH compute's VNC console goes through ssh -W

- The tunnel is `ssh -W` to the console's address or Unix socket: the
  compute needs no nc and runs no shell. The old tunnel, copied from
  virt-manager, built a shell script with the console's address and socket
  path in it and eval'd it on the compute, and always passed "-p 22",
  overriding a Port in the SSH configuration.
- On an SSH compute the console always goes through the tunnel, also when
  it listens on a network address: it was sent there unencrypted.
- ssh never prompts (BatchMode) and gives up connecting after 10 seconds;
  when it fails, what it said is logged. The tunnel ends with the session.
This commit is contained in:
catborise 2026-10-26 19:04:58 +03:00
parent 504d86faa3
commit 294b008811
4 changed files with 130 additions and 301 deletions

View file

@ -20,7 +20,7 @@ import socket
from urllib.parse import parse_qs, urlparse
import argparse
from console.sshtunnels import SSHTunnels
from console.sshtunnels import close_tunnel, open_tunnel, tunnel_target
from vrtManager.connection import CONN_SOCKET, CONN_SSH
from webvirtcloud.settings import WS_CERT, WS_HOST, WS_PORT
@ -95,29 +95,14 @@ def get_connection_infos(token):
instance.name,
uuid=instance.uuid, # the VM access was checked for, whatever its name now
)
if instance.compute.hostname.count(":"):
connhost = instance.compute.hostname.split(":")[0]
connport = instance.compute.hostname.split(":")[1]
else:
connhost = instance.compute.hostname
connport = 22
connuser = instance.compute.login
conntype = instance.compute.type
console_host = conn.get_console_listener_addr()
console_port = conn.get_console_port()
console_socket = conn.get_console_socket()
except Exception as e:
logging.error("Fail to retrieve console connection infos: %s", e)
raise
return (
connhost,
connport,
connuser,
conntype,
console_host,
console_port,
console_socket,
)
compute = instance.compute
return compute.hostname, compute.login, compute.type, console_host, console_port, console_socket
class CompatibilityMixIn(object):
@ -130,77 +115,36 @@ class CompatibilityMixIn(object):
return
try:
(
connhost,
connport,
connuser,
conntype,
console_host,
console_port,
console_socket,
) = get_connection_infos(token)
connhost, connuser, conntype, console_host, console_port, console_socket = get_connection_infos(token)
except Exception as e:
self.msg("Console connection denied: %s" % e)
return
cnx_debug_msg = "Connection infos :\n"
cnx_debug_msg += "- connhost : '%s'\n" % connhost
cnx_debug_msg += "- connport : '%s'\n" % connport
cnx_debug_msg += "- connuser : '%s'\n" % connuser
cnx_debug_msg += "- conntype : '%s'\n" % conntype
cnx_debug_msg += "- console_host : '%s'\n" % console_host
cnx_debug_msg += "- console_port : '%s'\n" % console_port
cnx_debug_msg += "- console_socket : '%s'\n" % console_socket
logging.debug(cnx_debug_msg)
logging.debug(
"Console of %s@%s (type %s): %s:%s, socket %s",
connuser, connhost, conntype, console_host, console_port, console_socket,
)
tunnel = None
if console_socket and conntype == CONN_SOCKET:
loopback = re.match(r"^127\.", console_host or "")
if conntype == CONN_SSH:
# the console travels inside the SSH connection, encrypted,
# whatever address it listens on
target = tunnel_target(console_host, console_port, console_socket)
self.msg("Opening an SSH tunnel to %s@%s, console %s" % (connuser, connhost, target))
tsock, tunnel = open_tunnel(connhost, connuser, target)
elif console_socket and conntype == CONN_SOCKET:
# Local socket on local host
self.msg("Try to open local socket %s" % console_socket)
tsock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
tsock.connect(console_socket)
elif conntype == CONN_SOCKET and re.match(r"^127\.", console_host):
elif conntype == CONN_SOCKET and loopback:
# libvirt runs on this host: its loopback is ours
self.msg("connecting to: %s:%s" % (console_host, console_port))
tsock = socket_factory(console_host, console_port, connect=True)
elif console_socket or re.match(r"^127\.", console_host):
# Need tunnel to physical host
if conntype != CONN_SSH:
message = "Need a tunnel to access console but can't mount one because it's not a SSH host"
self.msg(message)
raise Exception(message)
try:
# generate a string with all placeholders to avoid TypeErrors
# in sprintf
# https://github.com/retspen/webvirtmgr/pull/497
error_msg = "Try to open tunnel on %s@%s:%s on console %s:%s "
error_msg += "(or socket %s)"
self.msg(
error_msg
% (
connuser,
connhost,
connport,
console_host,
console_port,
console_socket,
)
)
tunnel = SSHTunnels(
connhost,
connuser,
connport,
console_host,
console_port,
console_socket,
)
fd = tunnel.open_new()
tunnel.unlock()
tsock = socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM)
except Exception as e:
self.msg("Fail to open tunnel : %s" % e)
raise
self.msg("Tunnel opened")
elif console_socket or loopback:
message = "The console listens only on its compute; it is reached through SSH computes only"
self.msg(message)
raise Exception(message)
else:
# Direct access
self.msg("connecting to: %s:%s" % (connhost, console_port))
@ -209,21 +153,15 @@ class CompatibilityMixIn(object):
if self.verbose and not daemon:
print(self.traffic_legend)
# Start proxying
try:
self.msg("Start proxying")
self.do_proxy(tsock)
except Exception:
finally:
tsock.close()
if tunnel:
self.vmsg(
"%s:%s (via %s@%s:%s) : Websocket client or Target closed"
% (console_host, console_port, connuser, connhost, connport)
)
if tsock:
tsock.shutdown(socket.SHUT_RDWR)
tsock.close()
tunnel.close_all()
raise
error = close_tunnel(tunnel)
if error:
logging.warning("SSH tunnel to %s@%s failed: %s", connuser, connhost, error)
if USE_HANDLER:

View file

@ -1,217 +1,41 @@
# Copyright (C) 2014, 2015 Red Hat, Inc.
#
# This work is licensed under the GNU GPLv2 or later.
# See the COPYING file in the top-level directory.
"""The VNC console of an SSH compute, carried by the SSH connection: `ssh -W`
forwards its standard input and output to the console's address or Unix
socket on the compute, so the compute needs no nc and runs no shell."""
import functools
import logging as log
import os
import queue
import signal
import ipaddress
import socket
import threading
import subprocess
class _TunnelScheduler(object):
"""
If the user is using an SSH URI without SSH keys, we need to
serialize connection opening otherwise ssh-askpass gets all angry.
This handles the locking and scheduling.
It's only instantiated once for the whole app, because we serialize
independent of connection, vm, etc.
"""
def __init__(self):
self._thread = None
self._queue = queue.Queue()
self._lock = threading.Lock()
def _handle_queue(self):
while True:
(
lock_cb,
cb,
args,
) = self._queue.get()
lock_cb()
cb(*args)
def schedule(self, lock_cb, cb, *args):
if not self._thread:
self._thread = threading.Thread(
name="Tunnel thread", target=self._handle_queue, args=()
)
self._thread.daemon = True
if not self._thread.is_alive():
self._thread.start()
self._queue.put((lock_cb, cb, args))
def lock(self):
self._lock.acquire()
def unlock(self):
self._lock.release()
def tunnel_target(address, port, unix_socket):
"""Where on the compute ssh forwards to: the Unix socket, or the address
and port, a wildcard or missing address meaning the compute's loopback."""
if unix_socket:
return unix_socket
address = {None: "127.0.0.1", "": "127.0.0.1", "0.0.0.0": "127.0.0.1", "::": "::1"}.get(address, address)
try:
if ipaddress.ip_address(address).version == 6:
address = f"[{address}]"
except ValueError: # a host name
pass
return f"{address}:{port}"
_tunnel_scheduler = _TunnelScheduler()
def open_tunnel(host, user, target):
"""A socket connected to target on host, and the ssh process carrying it.
ssh never asks for anything: a missing key or an unknown host key fails."""
ours, theirs = socket.socketpair()
argv = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-W", target]
if user:
argv += ["-l", user]
process = subprocess.Popen(argv + ["--", host], stdin=theirs, stdout=theirs, stderr=subprocess.PIPE)
theirs.close()
return ours, process
class _Tunnel(object):
def __init__(self):
self._pid = None
self._closed = False
self._errfd = None
def close(self):
if self._closed:
return
self._closed = True
log.debug(
"Close tunnel PID=%s ERRFD=%s",
self._pid,
self._errfd and self._errfd.fileno() or None,
)
# Since this is a socket object, the file descriptor is closed
# when it's garbage collected.
self._errfd = None
if self._pid:
os.kill(self._pid, signal.SIGKILL)
os.waitpid(self._pid, 0)
self._pid = None
def get_err_output(self):
errout = ""
while True:
try:
new = self._errfd.recv(1024)
except Exception:
break
if not new:
break
errout += new.decode()
return errout
def open(self, argv, sshfd):
if self._closed:
return
errfds = socket.socketpair()
pid = os.fork()
if pid == 0:
errfds[0].close()
os.dup2(sshfd.fileno(), 0)
os.dup2(sshfd.fileno(), 1)
os.dup2(errfds[1].fileno(), 2)
os.execlp(*argv)
os._exit(1) # pylint: disable=protected-access
sshfd.close()
errfds[1].close()
self._errfd = errfds[0]
self._errfd.setblocking(0)
log.debug("Opened tunnel PID=%d ERRFD=%d", pid, self._errfd.fileno())
self._pid = pid
def _make_ssh_command(connhost, connuser, connport, gaddr, gport, gsocket):
# Build SSH cmd
argv = ["ssh", "ssh"]
if connport:
argv += ["-p", str(connport)]
if connuser:
argv += ["-l", connuser]
argv += [connhost]
# Build 'nc' command run on the remote host
#
# This ugly thing is a shell script to detect availability of
# the -q option for 'nc': debian and suse based distros need this
# flag to ensure the remote nc will exit on EOF, so it will go away
# when we close the VNC tunnel. If it doesn't go away, subsequent
# VNC connection attempts will hang.
#
# Fedora's 'nc' doesn't have this option, and apparently defaults
# to the desired behavior.
#
if gsocket:
nc_params = "-U %s" % gsocket
else:
nc_params = "%s %s" % (gaddr, gport)
nc_cmd = (
"""nc -q 2>&1 | grep "requires an argument" >/dev/null;"""
"""if [ $? -eq 0 ] ; then"""
""" CMD="nc -q 0 %(nc_params)s";"""
"""else"""
""" CMD="nc %(nc_params)s";"""
"""fi;"""
"""eval "$CMD";""" % {"nc_params": nc_params}
)
argv.append("sh -c")
argv.append("'%s'" % nc_cmd)
argv_str = functools.reduce(lambda x, y: x + " " + y, argv[1:])
log.debug("Pre-generated ssh command for info: %s", argv_str)
return argv
class SSHTunnels(object):
def __init__(self, connhost, connuser, connport, gaddr, gport, gsocket):
self._tunnels = []
self._sshcommand = _make_ssh_command(
connhost, connuser, connport, gaddr, gport, gsocket
)
self._locked = False
def open_new(self):
t = _Tunnel()
self._tunnels.append(t)
# socket FDs are closed when the object is garbage collected. This
# can close an FD behind the VNC client's back which causes crashes.
#
# Dup a bare FD for the viewer side of things, but keep the high
# level socket object for the SSH side, since it simplifies things
# in that area.
viewerfd, sshfd = socket.socketpair()
_tunnel_scheduler.schedule(self._lock, t.open, self._sshcommand, sshfd)
retfd = os.dup(viewerfd.fileno())
log.debug("Generated tunnel fd=%s for viewer", retfd)
return retfd
def close_all(self):
for l in self._tunnels:
l.close()
self._tunnels = []
self.unlock()
def get_err_output(self):
errstrings = []
for l in self._tunnels:
e = l.get_err_output().strip()
if e and e not in errstrings:
errstrings.append(e)
return "\n".join(errstrings)
def _lock(self):
_tunnel_scheduler.lock()
self._locked = True
def unlock(self, *args, **kwargs):
if self._locked:
_tunnel_scheduler.unlock(*args, **kwargs)
self._locked = False
def close_tunnel(process):
"""End the ssh process; its error output, if it failed."""
if process.poll() is None:
process.terminate()
_, error = process.communicate()
return error.decode(errors="replace").strip() if process.returncode not in (0, -15) else ""

View file

@ -0,0 +1,42 @@
"""The SSH console tunnel: ssh -W to the console's address or socket, never a
prompt, no remote shell, and its error output when it fails."""
import sys
from unittest import TestCase
from unittest.mock import patch
from console import sshtunnels
class SshTunnelTests(TestCase):
def test_ssh_forwards_to_the_console_without_a_shell_or_a_prompt(self):
with patch.object(sshtunnels.subprocess, "Popen") as popen:
sock, process = sshtunnels.open_tunnel("kvm1.example.com", "root", "127.0.0.1:5900")
sock.close()
self.assertEqual(popen.call_args.args[0], [
"ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-W", "127.0.0.1:5900",
"-l", "root", "--", "kvm1.example.com"])
self.assertIs(process, popen.return_value)
def test_the_target_on_the_compute(self):
for address, unix_socket, target in (
("192.0.2.40", None, "192.0.2.40:5900"),
("2001:db8::40", None, "[2001:db8::40]:5900"),
(None, None, "127.0.0.1:5900"),
("0.0.0.0", None, "127.0.0.1:5900"),
("::", None, "[::1]:5900"),
("localhost", None, "localhost:5900"),
("127.0.0.1", "/run/vnc.sock", "/run/vnc.sock"),
):
with self.subTest(address=address, unix_socket=unix_socket):
self.assertEqual(sshtunnels.tunnel_target(address, 5900, unix_socket), target)
def test_a_failed_tunnel_reports_what_ssh_said(self):
failing = sshtunnels.subprocess.Popen(
[sys.executable, "-c", "import sys; sys.stderr.write('Host key verification failed.'); sys.exit(255)"],
stderr=sshtunnels.subprocess.PIPE)
failing.wait() # ssh fails at once, before the session
self.assertEqual(sshtunnels.close_tunnel(failing), "Host key verification failed.")
ended = sshtunnels.subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"],
stderr=sshtunnels.subprocess.PIPE)
self.assertEqual(sshtunnels.close_tunnel(ended), "") # ended with the session: no error

View file

@ -506,7 +506,7 @@ class NovncdDaemonLogicTestCase(TestCase):
@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", 22, "root", 1, "192.168.1.50", 5900, None)
mock_get_info.return_value = ("host1", "root", 1, "192.168.1.50", 5900, None)
handler = self.CompatibilityMixIn()
handler.headers = {"cookie": "token=1-uuid-of-another-tab"}
handler.path = "/novncd/?token=1-uuid-of-this-tab"
@ -520,7 +520,6 @@ class NovncdDaemonLogicTestCase(TestCase):
def test_compatibility_mixin_token_from_query_params(self, mock_get_info):
mock_get_info.return_value = (
"host2",
22,
"root",
1,
"192.168.1.50",
@ -544,7 +543,7 @@ class NovncdDaemonLogicTestCase(TestCase):
@patch.object(novncd_mod, "get_connection_infos")
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", None, "", novncd_mod.CONN_SOCKET, "127.0.0.1", 5902, None)
mock_get_info.return_value = ("localhost", "", novncd_mod.CONN_SOCKET, "127.0.0.1", 5902, None)
handler = self.CompatibilityMixIn()
handler.headers = {}
handler.path = "/novncd/?token=1-uuid"
@ -555,6 +554,32 @@ class NovncdDaemonLogicTestCase(TestCase):
handler._new_client(daemon=False, 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):
# whatever address the console listens on, the VNC stream is not sent unencrypted
for console_host, console_socket, target in (
("192.0.2.40", None, "192.0.2.40:5900"),
("0.0.0.0", None, "127.0.0.1:5900"),
("::", None, "[::1]:5900"),
("127.0.0.1", None, "127.0.0.1:5900"),
(None, "/run/libvirt/qemu/vnc.sock", "/run/libvirt/qemu/vnc.sock"),
):
with self.subTest(console_host=console_host), patch.object(
novncd_mod, "get_connection_infos",
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.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)
open_tunnel.assert_called_once_with("kvm1.example.com", "root", target)
socket_factory.assert_not_called()
# the tunnel ends with the session
close_tunnel.assert_called_once_with(open_tunnel.return_value[1])
@patch.object(novncd_mod, "get_connection_infos")
def test_compatibility_mixin_missing_token_safe_exit(self, mock_get_info):
handler = self.CompatibilityMixIn()
@ -594,7 +619,7 @@ class NovncdDaemonLogicTestCase(TestCase):
infos = novncd_mod.get_connection_infos(token)
self.assertEqual(infos[0], "127.0.0.1")
self.assertEqual(infos[5], 5900)
self.assertEqual(infos[4], 5900)
# the VM access was checked for, not whichever VM has its name now
self.assertEqual(mock_wvm.call_args.kwargs["uuid"], mock_instance.uuid)