1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/console/novncd
catborise 458074df82 fix(console): the console daemons hold to what the console page allowed
The token now names the console type it was made for: novncd accepts only
a VNC page's token and socketiod only a serial page's, so a VNC page's
token no longer opens the serial console without the VNC password.

A VNC session re-checks every 10 s, as the serial one did, that its user
may still open the console; a deactivated user or a removed owner loses
it. The check (may_still_open) moves to console.tokens for both daemons.
2026-11-05 16:16:22 +03:00

188 lines
7.2 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 logging
import os
import socket
import sys
import threading
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 console.tokens import ACCESS_CHECK, authorize, may_still_open # noqa: E402
from vrtManager.connection import CONN_SOCKET, CONN_SSH # noqa: E402
from vrtManager.util import is_loopback # 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(instance):
from vrtManager.instance import wvmInstance
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 end_when_refused(instance, user_id, tsock, ended):
"""Close the session's VNC socket, which ends it, once its user may no
longer open the console (as the serial console does)."""
while not ended.wait(ACCESS_CHECK):
if not may_still_open(user_id, instance):
logging.info("VNC console of VM %s closed: its user may no longer open it", instance.name)
try:
tsock.shutdown(socket.SHUT_RDWR)
except OSError:
pass # the session ended meanwhile
return
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:
instance, user = authorize(token, "vnc")
connhost, connuser, conntype, console_host, console_port, console_socket = get_connection_infos(instance)
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)
ended = threading.Event()
threading.Thread(target=end_when_refused, args=(instance, user.pk, tsock, ended), daemon=True).start()
try:
self.msg("Start proxying")
self.do_proxy(tsock)
finally:
ended.set()
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",
# a ping while the VM's screen stays the same: nginx, and a proxy on
# the way, end a websocket that sends nothing for 60 seconds
heartbeat=30,
).start_server()