diff --git a/conf/daemon/consolecallback b/conf/daemon/consolecallback new file mode 100755 index 0000000..d7810b9 --- /dev/null +++ b/conf/daemon/consolecallback @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# consolecallback - provide a persistent console that survives guest reboots + +import os +import logging +import libvirt +import tty +import termios +import atexit +from argparse import ArgumentParser +from typing import Optional # noqa F401 + + +def reset_term() -> None: + termios.tcsetattr(0, termios.TCSADRAIN, attrs) + + +def error_handler(unused, error) -> None: + # The console stream errors on VM shutdown; we don't care + if error[0] == libvirt.VIR_ERR_RPC and error[1] == libvirt.VIR_FROM_STREAMS: + return + logging.warn(error) + + +class Console(object): + def __init__(self, uri: str, uuid: str) -> None: + self.uri = uri + self.uuid = uuid + self.connection = libvirt.open(uri) + self.domain = self.connection.lookupByUUIDString(uuid) + self.state = self.domain.state(0) + self.connection.domainEventRegister(lifecycle_callback, self) + self.stream = None # type: Optional[libvirt.virStream] + self.run_console = True + self.stdin_watch = -1 + logging.info("%s initial state %d, reason %d", + self.uuid, self.state[0], self.state[1]) + + +def check_console(console: Console) -> bool: + if (console.state[0] == libvirt.VIR_DOMAIN_RUNNING or console.state[0] == libvirt.VIR_DOMAIN_PAUSED): + if console.stream is None: + console.stream = console.connection.newStream(libvirt.VIR_STREAM_NONBLOCK) + console.domain.openConsole(None, console.stream, 0) + console.stream.eventAddCallback(libvirt.VIR_STREAM_EVENT_READABLE, stream_callback, console) + else: + if console.stream: + console.stream.eventRemoveCallback() + console.stream = None + + return console.run_console + + +def stdin_callback(watch: int, fd: int, events: int, console: Console) -> None: + readbuf = os.read(fd, 1024) + if readbuf.startswith(b""): + console.run_console = False + return + if console.stream: + console.stream.send(readbuf) + + +def stream_callback(stream: libvirt.virStream, events: int, console: Console) -> None: + try: + assert console.stream + received_data = console.stream.recv(1024) + except Exception: + return + os.write(0, received_data) + + +def lifecycle_callback(connection: libvirt.virConnect, domain: libvirt.virDomain, event: int, detail: int, console: Console) -> None: + console.state = console.domain.state(0) + logging.info("%s transitioned to state %d, reason %d", + console.uuid, console.state[0], console.state[1]) + + +# main +parser = ArgumentParser(epilog="Example: %(prog)s 'qemu:///system' '32ad945f-7e78-c33a-e96d-39f25e025d81'") +parser.add_argument("uri") +parser.add_argument("uuid") +args = parser.parse_args() + +print("Escape character is ^]") +logging.basicConfig(filename='msg.log', level=logging.DEBUG) +logging.info("URI: %s", args.uri) +logging.info("UUID: %s", args.uuid) + +libvirt.virEventRegisterDefaultImpl() +libvirt.registerErrorHandler(error_handler, None) + +atexit.register(reset_term) +attrs = termios.tcgetattr(0) +tty.setraw(0) + +console = Console(args.uri, args.uuid) +console.stdin_watch = libvirt.virEventAddHandle(0, libvirt.VIR_EVENT_HANDLE_READABLE, stdin_callback, console) + +while check_console(console): + libvirt.virEventRunDefaultImpl() diff --git a/conf/nginx/webvirtcloud.conf b/conf/nginx/webvirtcloud.conf index d16a814..3ba36ec 100644 --- a/conf/nginx/webvirtcloud.conf +++ b/conf/nginx/webvirtcloud.conf @@ -28,8 +28,17 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } + location /socket.io/ { + proxy_pass http://wssocketiod; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } } upstream wsnovncd { server 127.0.0.1:6080; -} \ No newline at end of file +} +upstream wssocketiod { + server 127.0.0.1:6081; +} diff --git a/conf/requirements.txt b/conf/requirements.txt index 6d17fa3..99629fb 100644 --- a/conf/requirements.txt +++ b/conf/requirements.txt @@ -13,3 +13,4 @@ rwlock==0.0.7 websockify==0.10.0 zipp==3.6.0 ldap3==2.9.1 +python-socketio==5.7.0 diff --git a/conf/supervisor/webvirtcloud.conf b/conf/supervisor/webvirtcloud.conf index 692fe8a..a554f0d 100644 --- a/conf/supervisor/webvirtcloud.conf +++ b/conf/supervisor/webvirtcloud.conf @@ -12,4 +12,12 @@ directory=/srv/webvirtcloud user=www-data autostart=true autorestart=true -redirect_stderr=true \ No newline at end of file +redirect_stderr=true + +[program:socketiod] +command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/socketiod -d +directory=/srv/webvirtcloud +user=www-data +autostart=true +autorestart=true +redirect_stderr=true diff --git a/console/socketiod b/console/socketiod new file mode 100755 index 0000000..a3e2bdc --- /dev/null +++ b/console/socketiod @@ -0,0 +1,195 @@ +#!/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 tempfile +import io +import socket +import socketio +import pty +import select +import subprocess +import struct +import fcntl +import termios +import signal +import eventlet +import atexit +import tty +import termios +import libvirt + +from six.moves import http_cookies as Cookie +from webvirtcloud.settings import SOCKETIO_PORT, SOCKETIO_HOST +from vrtManager.connection import CONN_SSH, CONN_SOCKET +from optparse import OptionParser + +parser = OptionParser() + +parser.add_option("-v", + "--verbose", + dest="verbose", + action="store_true", + help="Verbose mode", + default=False) + +parser.add_option("-d", + "--debug", + dest="debug", + action="store_true", + help="Debug mode", + default=False) + +parser.add_option("-H", + "--host", + dest="host", + action="store", + help="Listen host", + default=SOCKETIO_HOST) + +parser.add_option("-p", + "--port", + dest="port", + action="store", + help="Listen port", + default=SOCKETIO_PORT or 6081) + +(options, args) = 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) + +async_mode = "eventlet" +sio = socketio.Server(async_mode=async_mode, cors_allowed_origins="https://vmm.cyborgside.net") + +fd = None +child_pid = None + +def get_connection_infos(token): + from instances.models import Instance + from vrtManager.instance import wvmInstance + + try: + temptoken = token.split('-', 1) + host = int(temptoken[0]) + uuid = temptoken[1] + instance = Instance.objects.get(compute_id=host, uuid=uuid) + conn = wvmInstance(instance.compute.hostname, + instance.compute.login, + instance.compute.password, + instance.compute.type, + instance.name) + except Exception as e: + logging.error( + 'Fail to retrieve console connection infos for token %s : %s' % (token, e)) + raise + return (instance, conn) + +def set_winsize(fd, row, col, xpix=0, ypix=0): + winsize = struct.pack("HHHH", row, col, xpix, ypix) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + + +def read_and_forward_pty_output(): + global fd + max_read_bytes = 1024 * 20 + while True: + sio.sleep(0.01) + if fd: + timeout_sec = 0 + (data_ready, _, _) = select.select([fd], [], [], timeout_sec) + if data_ready: + output = os.read(fd, max_read_bytes).decode() + sio.emit("pty_output", {"output": output}) + else: + return + + +@sio.event +def resize(sid, message): + global fd + if fd: + set_winsize(fd, message["rows"], message["cols"]) + +@sio.event +def pty_input(sid, message): + global fd + if fd: + os.write(fd, message["input"].encode()) + +@sio.event +def disconnect_request(sid): + sio.disconnect(sid) + +@sio.event +def connect(sid, environ): + global fd + global child_pid + + hcookie = environ.get('HTTP_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: + logging.warn('Found malformed cookie') + else: + if 'token' in cookie: + token = cookie['token'].value + + if child_pid: + # already started child process, don't start another + # write a new line so that when a client refresh the shell prompt is printed + fd.write("\n") + return + + # create child process attached to a pty we can read from and write to + (child_pid, fd) = pty.fork() + + if child_pid == 0: + (instance, conn) = get_connection_infos(token) + uuid = conn.get_uuid() + uri = conn.wvm.getURI() + subprocess.run(["consolecallback", uri, uuid]) + else: + # this is the parent process fork. + sio.start_background_task(target=read_and_forward_pty_output) + +@sio.event +def disconnect(sid): + + global fd + global child_pid + + # kill pty process + os.kill(child_pid,signal.SIGKILL) + os.wait() + + # reset the variables + fd = None + child_pid = None + +app = socketio.WSGIApp(sio) +import eventlet +eventlet.wsgi.server(eventlet.listen((options.host,int(options.port))), app) diff --git a/console/templates/console-xterm.html b/console/templates/console-xterm.html new file mode 100644 index 0000000..21fe8c2 --- /dev/null +++ b/console/templates/console-xterm.html @@ -0,0 +1,75 @@ +{% extends "console-base.html" %} +{% load i18n %} +{% load static %} + +{% block head %} +WebVirtCloud - XTerm + + + + + + + +{% endblock %} +{% block content %} +
+ Status: connecting... + +
+ +
+ +{% endblock %} diff --git a/console/views.py b/console/views.py index 81f5465..a83a0bd 100644 --- a/console/views.py +++ b/console/views.py @@ -79,6 +79,8 @@ def console(request): if console_type == "vnc" or console_type == "spice": console_page = "console-" + console_type + "-" + view_type + ".html" response = render(request, console_page, locals()) + elif console_type == "pty": + response = render(request, "console-xterm.html", locals()) else: if console_type is None: console_error = _("Fail to get console. Please check the console configuration of your VM.") diff --git a/vrtManager/instance.py b/vrtManager/instance.py index 63fd835..8212aab 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -1019,6 +1019,8 @@ class wvmInstance(wvmConnect): def get_console_type(self): console_type = util.get_xml_path(self._XMLDesc(0), "/domain/devices/graphics/@type") + if console_type is None: + console_type = util.get_xml_path(self._XMLDesc(0), "/domain/devices/console/@type") return console_type def set_console_type(self, console_type): diff --git a/webvirtcloud.sh b/webvirtcloud.sh index d9aa0c7..36286b3 100755 --- a/webvirtcloud.sh +++ b/webvirtcloud.sh @@ -254,6 +254,7 @@ install_webvirtcloud () { pip3 install -r conf/requirements.txt -q + cp "$APP_PATH/conf/daemon/consolecallback" "$APP_PATH/venv/bin/consolecallback" chown -R "$nginx_group":"$nginx_group" "$APP_PATH" diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index ffaacf3..6818262 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -198,6 +198,9 @@ WS_PUBLIC_PATH = "/novncd/" # Websock Certificate for SSL WS_CERT = None +SOCKETIO_PORT = 6081 +SOCKETIO_HOST = '0.0.0.0' + # List of console listen addresses QEMU_CONSOLE_LISTEN_ADDRESSES = ( ("127.0.0.1", "Localhost"),