mirror of
https://github.com/retspen/webvirtcloud
synced 2024-12-25 07:35:22 +00:00
Merge pull request #519 from GaetanF/master
Implement Libvirt Serial Console as Console on WebVirtCloud
This commit is contained in:
commit
92a20c0aaa
10 changed files with 398 additions and 2 deletions
100
conf/daemon/consolecallback
Executable file
100
conf/daemon/consolecallback
Executable file
|
@ -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()
|
|
@ -28,8 +28,17 @@ server {
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "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 {
|
upstream wsnovncd {
|
||||||
server 127.0.0.1:6080;
|
server 127.0.0.1:6080;
|
||||||
}
|
}
|
||||||
|
upstream wssocketiod {
|
||||||
|
server 127.0.0.1:6081;
|
||||||
|
}
|
||||||
|
|
|
@ -13,3 +13,4 @@ rwlock==0.0.7
|
||||||
websockify==0.10.0
|
websockify==0.10.0
|
||||||
zipp==3.6.0
|
zipp==3.6.0
|
||||||
ldap3==2.9.1
|
ldap3==2.9.1
|
||||||
|
python-socketio==5.7.0
|
||||||
|
|
|
@ -12,4 +12,12 @@ directory=/srv/webvirtcloud
|
||||||
user=www-data
|
user=www-data
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
redirect_stderr=true
|
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
|
||||||
|
|
195
console/socketiod
Executable file
195
console/socketiod
Executable file
|
@ -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(["/srv/webvirtcloud/venv/bin/python3", "/srv/webvirtcloud/venv/bin/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)
|
75
console/templates/console-xterm.html
Normal file
75
console/templates/console-xterm.html
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
{% extends "console-base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<title>WebVirtCloud - XTerm</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/xterm@3.6.0/dist/xterm.css" />
|
||||||
|
<script src="https://unpkg.com/xterm@3.6.0/dist/xterm.js"></script>
|
||||||
|
<script src="https://unpkg.com/xterm@3.6.0/dist/addons/fit/fit.js"></script>
|
||||||
|
<script src="https://unpkg.com/xterm@3.6.0/dist/addons/fullscreen/fullscreen.js"></script>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.0/socket.io.js"></script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="background: white; padding-bottom: 5px;">
|
||||||
|
<span style="font-size: small;">Status: <span style="font-size: small;" id="status">connecting...</span></span>
|
||||||
|
<button id="button"; type="button"; onclick="myFunction()";>Connect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="width: 100%; height:100%;" id="terminal"></div>
|
||||||
|
<script crossorigin="anonymous">
|
||||||
|
Terminal.applyAddon(fit)
|
||||||
|
|
||||||
|
var socket = io.connect({transports: ["websocket", "polling"]});
|
||||||
|
|
||||||
|
const status = document.getElementById("status")
|
||||||
|
const button = document.getElementById("button")
|
||||||
|
|
||||||
|
var term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
term.open(document.getElementById('terminal'));
|
||||||
|
|
||||||
|
term.on('key', (key, ev) => {
|
||||||
|
console.log("pressed key", key)
|
||||||
|
socket.emit("pty_input", {"input": key})
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("pty_output", function(output){
|
||||||
|
console.log(output["output"])
|
||||||
|
term.write(output["output"])
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
status.innerHTML = '<span style="background-color: lightgreen;">connected</span>'
|
||||||
|
button.innerHTML = 'Disconnect'
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
status.innerHTML = '<span style="background-color: #ff8383;">disconnected</span>'
|
||||||
|
button.innerHTML = 'Connect'
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
function myFunction(){
|
||||||
|
if (button.innerHTML =='Connect'){
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (button.innerHTML == "Disconnect"){
|
||||||
|
socket.emit("disconnect_request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(){
|
||||||
|
term.fit()
|
||||||
|
socket.emit("resize", {"cols": term.cols, "rows": term.rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onresize = resize
|
||||||
|
window.onload = resize
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -79,6 +79,8 @@ def console(request):
|
||||||
if console_type == "vnc" or console_type == "spice":
|
if console_type == "vnc" or console_type == "spice":
|
||||||
console_page = "console-" + console_type + "-" + view_type + ".html"
|
console_page = "console-" + console_type + "-" + view_type + ".html"
|
||||||
response = render(request, console_page, locals())
|
response = render(request, console_page, locals())
|
||||||
|
elif console_type == "pty":
|
||||||
|
response = render(request, "console-xterm.html", locals())
|
||||||
else:
|
else:
|
||||||
if console_type is None:
|
if console_type is None:
|
||||||
console_error = _("Fail to get console. Please check the console configuration of your VM.")
|
console_error = _("Fail to get console. Please check the console configuration of your VM.")
|
||||||
|
|
|
@ -1019,6 +1019,8 @@ class wvmInstance(wvmConnect):
|
||||||
|
|
||||||
def get_console_type(self):
|
def get_console_type(self):
|
||||||
console_type = util.get_xml_path(self._XMLDesc(0), "/domain/devices/graphics/@type")
|
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
|
return console_type
|
||||||
|
|
||||||
def set_console_type(self, console_type):
|
def set_console_type(self, console_type):
|
||||||
|
|
|
@ -254,6 +254,7 @@ install_webvirtcloud () {
|
||||||
pip3 install -r conf/requirements.txt -q
|
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"
|
chown -R "$nginx_group":"$nginx_group" "$APP_PATH"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -198,6 +198,9 @@ WS_PUBLIC_PATH = "/novncd/"
|
||||||
# Websock Certificate for SSL
|
# Websock Certificate for SSL
|
||||||
WS_CERT = None
|
WS_CERT = None
|
||||||
|
|
||||||
|
SOCKETIO_PORT = 6081
|
||||||
|
SOCKETIO_HOST = '0.0.0.0'
|
||||||
|
|
||||||
# List of console listen addresses
|
# List of console listen addresses
|
||||||
QEMU_CONSOLE_LISTEN_ADDRESSES = (
|
QEMU_CONSOLE_LISTEN_ADDRESSES = (
|
||||||
("127.0.0.1", "Localhost"),
|
("127.0.0.1", "Localhost"),
|
||||||
|
|
Loading…
Reference in a new issue