mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
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.
365 lines
12 KiB
Python
Executable file
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()
|