1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/console/novncd
catborise b150a9dd36 fix: stop ignoring SIGCHLD process-wide
The web app (wsgi.py), novncd and socketiod set SIGCHLD to SIG_IGN at
import. The kernel then reaps every child at once, so subprocess loses
the exit status and a failing command reports 0, in the server and in
anything else run in that process: the test suite had to reset the
signal in two tests.

It was added in 2023 against one defunct bash per gunicorn worker and
per novncd. That bash came from the settings template of the time,
which ran .dec_ldap_pwd.sh with Popen and never waited for it; the
template stopped doing that in 561fedf. A settings.py still holding
that line leaves at most one zombie per process, not a growing number.

- wsgi.py, novncd, socketiod: the global SIG_IGN is removed. subprocess
  reaps its own children, websockify installs its own SIGCHLD reaper
  for novncd's connection processes, and novncd's SSH tunnels wait for
  their ssh process.
- socketiod: the pty child execs consolecallback instead of running it
  and then returning into the server's code; disconnect reaps that
  child (waitpid) instead of any child (wait).
- The settings template drops its unused subprocess import; the two
  test workarounds are removed.

New tests load the web app and novncd in a fresh interpreter and check
that SIGCHLD is untouched and a failing command still returns 1; both
fail on the previous code. A patched novncd in the test container
served five connections and left no child behind.
2026-10-04 13:40:53 +03:00

365 lines
12 KiB
Python
Executable file

#!/usr/bin/env python3
import os
import sys
import logging
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"
if ROOT_PATH not in sys.path:
sys.path.append(ROOT_PATH)
django.setup()
import re
import socket
# from six.moves import http_cookies as Cookie
from http import cookies as Cookie
import argparse
from console.sshtunnels import SSHTunnels
from vrtManager.connection import CONN_SOCKET, CONN_SSH
from webvirtcloud.settings import WS_CERT, WS_HOST, WS_PORT
def get_parser(default_host=WS_HOST, default_port=WS_PORT or 6080, default_cert=WS_CERT or CERT):
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,
)
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 django.contrib.auth import get_user_model
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from instances.models import Instance
from instances.utils import can_open_console
from vrtManager.instance import wvmInstance
# The console view signs exactly "host:uuid:user_id". Anything else
# (unsigned, expired, or without the user part) is rejected, because the
# per-user access check below depends on user_id.
signer = TimestampSigner(salt="console.novnc")
try:
unsigned = signer.unsign(token, max_age=120)
host, uuid, user_id = unsigned.split(":")
host = int(host)
user_id = int(user_id)
except (BadSignature, SignatureExpired, ValueError, TypeError):
raise PermissionError("Invalid or expired console token")
try:
instance = Instance.objects.get(compute_id=host, uuid=uuid)
User = get_user_model()
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
raise PermissionError("User not found")
if not user.is_active:
raise PermissionError("User account is inactive or disabled")
if not can_open_console(user, instance):
raise PermissionError("User no longer has access to this instance")
conn = wvmInstance(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
instance.name,
)
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,
)
class CompatibilityMixIn(object):
def _new_client(self, daemon, socket_factory):
# NoVNC uses it's own convention that forward token
# from the request to a cookie header, we should check
# also for this behavior
token = None
hcookie = self.headers.get("cookie")
if hcookie:
cookie = Cookie.SimpleCookie()
for hcookie_part in hcookie.split(";"):
hcookie_part = hcookie_part.lstrip()
try:
cookie.load(hcookie_part)
except Cookie.CookieError:
# NOTE(stgleb): Do not print out cookie content
# for security reasons.
self.msg("Found malformed cookie")
else:
if "token" in cookie:
token = cookie["token"].value
# Fallback to query parameter if token not found in cookie
if not token and hasattr(self, "path"):
from urllib.parse import parse_qs, urlparse
try:
parsed_url = urlparse(self.path)
query_params = parse_qs(parsed_url.query)
if "token" in query_params:
token = query_params["token"][0]
except Exception:
pass
if not token:
self.msg("No console token provided in cookie or query parameters")
return
try:
(
connhost,
connport,
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)
if 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 console_socket or re.match(r"^127\.", console_host):
# Need tunnel to physical host
if conntype != CONN_SSH:
self.msg(
"Need a tunnel to access console but can't mount "
+ "one because it's not a SSH host"
)
raise Exception(self.msg)
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")
else:
# Direct access
self.msg("connecting to: %s:%s" % (connhost, console_port))
tsock = socket_factory(connhost, console_port, connect=True)
tunnel = None
if self.verbose and not daemon:
print(self.traffic_legend)
# Start proxying
try:
self.msg("Start proxying")
self.do_proxy(tsock)
except Exception:
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
if USE_HANDLER:
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)
if __name__ == "__main__":
options = get_parser().parse_args()
FORMAT = "%(asctime)s - %(name)s - %(levelname)s : %(message)s"
if options.debug:
logging.basicConfig(level=logging.DEBUG, format=FORMAT)
options.verbose = True
elif options.verbose:
logging.basicConfig(level=logging.INFO, format=FORMAT)
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()