1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00
webvirtcloud/console/novncd
catborise 1d2de7404b fix(console): an SSH console tunnel neither stalls on its errors nor hangs on close
ssh's error output went to a pipe that nobody read while the console ran:
once it filled, ssh stopped and the console froze. It now goes to a
temporary file, read when the tunnel closes. Closing waits five seconds
for ssh to end and then kills it, instead of waiting for ever. The Tunnel
holds the ssh process and its error file together.
2026-10-26 23:52:37 +03:00

176 lines
6.4 KiB
Python
Executable file

#!/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 argparse
import ipaddress
import logging
import os
import socket
import sys
from urllib.parse import parse_qs, urlparse
import django
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()
from websockify import ProxyRequestHandler, WebSocketProxy # noqa: E402
from console.sshtunnels import 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):
parser = argparse.ArgumentParser(description="WebVirtCloud noVNC daemon")
parser.add_argument(
"-v",
"--verbose",
dest="verbose",
action="store_true",
help="Verbose mode",
default=False,
)
parser.add_argument(
"-d", "--debug", dest="debug", action="store_true", help="Debug mode", default=False
)
parser.add_argument(
"-H", "--host", dest="host", action="store", help="Listen host", default=default_host
)
parser.add_argument(
"-p",
"--port",
dest="port",
action="store",
type=int,
help="Listen port",
default=default_port,
)
return parser
def get_connection_infos(token):
from console.tokens import authorize
from vrtManager.instance import wvmInstance
instance, _user = authorize(token)
conn = wvmInstance(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
instance.name,
uuid=instance.uuid, # the VM access was checked for, whatever its name now
)
console_host = conn.get_console_listener_addr()
console_port = conn.get_console_port()
console_socket = conn.get_console_socket()
compute = instance.compute
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 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]
if not token:
logging.warning("Console connection without a token in its websocket URL")
return
try:
connhost, connuser, conntype, console_host, console_port, console_socket = get_connection_infos(token)
except Exception as e:
logging.warning("Console connection denied: %s", e)
return
logging.debug(
"Console of %s@%s (type %s): %s:%s, socket %s",
connuser, connhost, conntype, console_host, console_port, console_socket,
)
tunnel = None
loopback = is_loopback(console_host)
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 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 loopback:
message = "The console listens only on its compute; it is reached through SSH computes only"
self.msg(message)
raise Exception(message)
else:
# 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)
try:
self.msg("Start proxying")
self.do_proxy(tsock)
finally:
tsock.close()
if tunnel:
error = tunnel.close()
if error:
logging.warning("SSH tunnel to %s@%s failed: %s", connuser, connhost, error)
class ConsoleProxyHandler(ProxyRequestHandler, ConsoleClient):
def msg(self, *args, **kwargs):
self.log_message(*args, **kwargs)
def new_websocket_client(self):
self._new_client(self.server.socket)
if __name__ == "__main__":
options = get_parser().parse_args()
# to stderr (the journal, docker logs), not the panel's own log file:
# each process rotating that file would lose lines. force: Django's
# settings already gave the root logger that file.
level = logging.DEBUG if options.debug else logging.INFO if options.verbose else logging.WARNING
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s", force=True)
options.verbose = options.verbose or options.debug
# 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()