1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

feat(console): one VNC console page, driven from the panel's bar

The VM page offered a lite and a full VNC console. Lite lacked a clipboard,
display settings and a way to reconnect; full put noVNC's own side bar under
the panel's bar, with a Fullscreen item that called a function noVNC does
not have, status messages hidden behind the bar, saved noVNC settings that
overrode the Access tab's, connection settings that cannot apply, and a
Connect button that reused the page's two-minute token and failed.

console-vnc.html and static/js/console_vnc.js replace both, on noVNC's RFB
with Bootstrap controls, and keep what the full view did:
- Status in the bar (Connecting, Connected, Disconnected, Connection lost,
  Could not connect, Authentication failed with the server's reason on
  hover); Disconnect, then Reconnect, which loads the page again for a new
  token.
- The VNC password in a dialog (with a username when the server asks for
  one), not a browser prompt; without passwordless console it is not in
  the page.
- Send key(s) gains Hold Ctrl / Alt / Windows (noVNC's extra keys).
- Clipboard: what the VM copies, sending text to the VM's clipboard, and
  typing it key by key (new: works in firmware, installers and login
  prompts; Shift for the characters a US keyboard shifts, 25 ms a key as
  firmware drops faster keys, verified in a UEFI shell).
- View: scaling (none, fit, resize the VM's screen), clip, drag a clipped
  screen, view only, dot cursor, quality, compression; it starts as
  Settings > Console says (a touch screen starts fitted).
- A Keyboard item on touch devices (noVNC's on-screen keyboard input,
  key events and Android's text-only input), Fullscreen with keyboard
  lock, the bell.
Dropped with the full view: noVNC's websocket host/port/path, repeater ID
and log level (the panel fixes them), automatic reconnect (the token is
single use in time), XVP power buttons (QEMU has no XVP) and shared-mode off.

The bar is compact (about 45 px in the 850x600 window). The Access tab has
one Console button instead of the Lite/Full split and four switches, and
it and the VM list open one window per VM (opening it again reloads it).
This commit is contained in:
catborise 2026-10-27 00:25:27 +03:00
parent 3a560e9f85
commit 0532471759
12 changed files with 520 additions and 843 deletions

View file

@ -21,7 +21,7 @@
padding: 0;
width: 100%;
max-width: none;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
background-color: #494949;
@ -42,9 +42,10 @@
<body>
<div id="main_container">
<nav class="navbar sticky-top navbar-expand-md navbar-dark bg-primary" aria-label="console navbar">
<nav class="navbar navbar-expand-md navbar-dark bg-primary py-1" aria-label="{% trans "Console" %}">
<div class="container-fluid">
<a class="navbar-brand">{{ instance.name }}</a>
<span class="navbar-brand py-0">{{ instance.name }}</span>
{% block navbarstatus %}{% endblock %}
{% block navbar %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse"
aria-expanded="false" aria-label="Toggle navigation">
@ -52,9 +53,10 @@
</button>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto my-2 my-lg-0">
<ul class="navbar-nav me-auto">
{% block navbarmenu %}{% endblock %}
</ul>
{% block navbarend %}{% endblock %}
</div>
{% endblock %}
</div>

View file

@ -5,7 +5,7 @@
id="dropdownMenuLink" aria-haspopup="true" aria-expanded="false">
{% trans "Send key(s)" %}
</a>
<div class="dropdown-menu shadow" aria-labelledby="dropdownMenuLink" style="min-width: 250px;">
<div class="dropdown-menu shadow overflow-auto" aria-labelledby="dropdownMenuLink" style="min-width: 250px; max-height: calc(100dvh - 4rem);">
{% if not serial %}
<h6 class="dropdown-header">{% trans "System" %}</h6>
<a class="dropdown-item" id="ctrlaltdel" href="#">Ctrl+Alt+Del</a>
@ -45,6 +45,11 @@
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf12" href="#">F12</a></div>
</div>
</div>
<div class="dropdown-divider"></div>
<h6 class="dropdown-header">{% trans "Hold until chosen again" %}</h6>
<a class="dropdown-item" id="holdctrl" href="#" role="button" aria-pressed="false">Ctrl</a>
<a class="dropdown-item" id="holdalt" href="#" role="button" aria-pressed="false">Alt</a>
<a class="dropdown-item" id="holdsuper" href="#" role="button" aria-pressed="false">Windows / Super Key</a>
{% endif %}
</div>
</li>

View file

@ -1,382 +0,0 @@
{% extends "console-base.html" %}
{% load i18n %}
{% load static %}
{% block head %}
<!--
noVNC example: simple example using default UI
Copyright (C) 2018 The noVNC Authors
noVNC is licensed under the MPL 2.0 (see LICENSE.txt)
This file is licensed under the 2-Clause BSD license (see LICENSE.txt).
Connect parameters are provided in query string:
http://example.com/?host=HOST&port=PORT&encrypt=1
or the fragment:
http://example.com/#host=HOST&port=PORT&encrypt=1
-->
<meta charset="utf-8" />
<!-- Always force latest IE rendering engine (even in intranet) & Chrome Frame
Remove this if you use the .htaccess -->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<link rel="icon" type="image/x-icon" href="{% static "js/novnc/app/images/icons/novnc.ico" %}">
<link rel="icon" sizes="any" type="image/svg+xml" href="{% static "js/novnc/app/images/icons/novnc-icon.svg" %}">
<!-- Apple iOS Safari settings -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- iOS Home Screen Icons -->
<link rel="apple-touch-icon" sizes="40x40" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-40.png" %}">
<link rel="apple-touch-icon" sizes="58x58" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-58.png" %}">
<link rel="apple-touch-icon" sizes="60x60" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-60.png" %}">
<link rel="apple-touch-icon" sizes="76x76" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-76.png" %}">
<link rel="apple-touch-icon" sizes="80x80" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-80.png" %}">
<link rel="apple-touch-icon" sizes="87x87" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-87.png" %}">
<link rel="apple-touch-icon" sizes="120x120" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-120.png" %}">
<link rel="apple-touch-icon" sizes="152x152" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-152.png" %}">
<link rel="apple-touch-icon" sizes="167x167" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-167.png" %}">
<link rel="apple-touch-icon" sizes="180x180" type="image/png" href="{% static "js/novnc/app/images/icons/novnc-ios-180.png" %}">
<!-- Stylesheets -->
<link rel="stylesheet" href="{% static "js/novnc/app/styles/constants.css" %}">
<link rel="stylesheet" href="{% static "js/novnc/app/styles/base.css" %}">
<link rel="stylesheet" href="{% static "js/novnc/app/styles/input.css" %}">
<!-- Images that will later appear via CSS -->
<link rel="preload" as="image" href="{% static "js/novnc/app/images/info.svg" %}">
<link rel="preload" as="image" href="{% static "js/novnc/app/images/error.svg" %}">
<link rel="preload" as="image" href="{% static "js/novnc/app/images/warning.svg" %}">
<script type="module" crossorigin="anonymous" src="{% static "js/novnc/app/error-handler.js" %}"></script>
<!-- actual script modules -->
<script type="module" crossorigin="anonymous">
import UI from '{% static "js/novnc/app/ui.js" %}';
import { bindKeyAction, bindSendKeys } from '{% static "js/console_keys.js" %}';
bindKeyAction('fullscreen_button', () => {
if (typeof UI.fullscreen === 'function') {
UI.fullscreen();
}
});
bindSendKeys(() => UI.rfb);
// novncd behind the panel's own address (nginx's /novncd/)
let fullPath = 'novncd/';
const token = '{{ token|escapejs }}';
if (token) {
fullPath += (fullPath.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
}
const defaults = {
autoconnect: true,
resize: '{% if resize_session %}remote{% elif scale %}scale{% else %}off{% endif %}',
view_only: {{ view_only|yesno:"true,false" }},
view_clip: {{ clip_viewport|yesno:"true,false" }},
{% if perms.instances.passwordless_console and console_passwd %}
password: '{{ console_passwd|escapejs }}',
{% endif %}
};
const https = window.location.protocol === 'https:';
const mandatory = {
host: window.location.hostname,
port: Number(window.location.port || (https ? 443 : 80)),
path: fullPath,
encrypt: https,
};
UI.start({ settings: { defaults, mandatory } });
</script>
<!-- end scripts -->
{% endblock %}
{% block navbarmenu %}{% include "console-menu.html" %}{% endblock %}
{% block content %}
<div id="noVNC_fallback_error" class="noVNC_center">
<div>
<div>{% trans 'noVNC encountered an error' %}:</div>
<br>
<div id="noVNC_fallback_errormsg"></div>
</div>
</div>
<!-- noVNC Control Bar -->
<div id="noVNC_control_bar_anchor" class="noVNC_vcenter">
<div id="noVNC_control_bar">
<div id="noVNC_control_bar_handle" title="Hide/Show the control bar">
<div></div>
</div>
<div class="noVNC_scroll">
<h1 class="noVNC_logo" translate="no"><span>no</span><br>VNC</h1>
<!-- Drag/Pan the viewport -->
<input type="image" alt="viewport drag" src="{% static "js/novnc/app/images/drag.svg" %}"
id="noVNC_view_drag_button" class="noVNC_button noVNC_hidden" title="Move/Drag Viewport" />
<!--noVNC Touch Device only buttons-->
<div id="noVNC_mobile_buttons">
<input type="image" alt="Keyboard" src="{% static "js/novnc/app/images/keyboard.svg" %}"
id="noVNC_keyboard_button" class="noVNC_button" title="Show Keyboard">
</div>
<!-- Extra manual keys -->
<input type="image" alt="Extra keys" src="{% static "js/novnc/app/images/toggleextrakeys.svg" %}"
id="noVNC_toggle_extra_keys_button" class="noVNC_button" title="Show Extra Keys" />
<div class="noVNC_vcenter">
<div id="noVNC_modifiers" class="noVNC_panel">
<input type="image" alt="Ctrl" src="{% static "js/novnc/app/images/ctrl.svg" %}"
id="noVNC_toggle_ctrl_button" class="noVNC_button" title="Toggle Ctrl" />
<input type="image" alt="Alt" src="{% static "js/novnc/app/images/alt.svg" %}"
id="noVNC_toggle_alt_button" class="noVNC_button" title="Toggle Alt" />
<input type="image" alt="Windows" src="{% static "js/novnc/app/images/windows.svg" %}"
id="noVNC_toggle_windows_button" class="noVNC_button" title="Toggle Windows">
<input type="image" alt="Tab" src="{% static "js/novnc/app/images/tab.svg" %}"
id="noVNC_send_tab_button" class="noVNC_button" title="Send Tab" />
<input type="image" alt="Esc" src="{% static "js/novnc/app/images/esc.svg" %}"
id="noVNC_send_esc_button" class="noVNC_button" title="Send Escape" />
<input type="image" alt="Ctrl+Alt+Del" src="{% static "js/novnc/app/images/ctrlaltdel.svg" %}"
id="noVNC_send_ctrl_alt_del_button" class="noVNC_button" title="Send Ctrl-Alt-Del" />
</div>
</div>
<!-- Shutdown/Reboot -->
<input type="image" alt="Shutdown/Reboot" src="{% static "js/novnc/app/images/power.svg" %}"
id="noVNC_power_button" class="noVNC_button" title="Shutdown/Reboot..." />
<div class="noVNC_vcenter">
<div id="noVNC_power" class="noVNC_panel">
<div class="noVNC_heading">
<img src="{% static "js/novnc/app/images/power.svg" %}"> Power
</div>
<input type="button" id="noVNC_shutdown_button" value="Shutdown" />
<input type="button" id="noVNC_reboot_button" value="Reboot" />
<input type="button" id="noVNC_reset_button" value="Reset" />
</div>
</div>
<!-- Clipboard -->
<input type="image" alt="Clipboard" src="{% static "js/novnc/app/images/clipboard.svg" %}"
id="noVNC_clipboard_button" class="noVNC_button" title="Clipboard" />
<div class="noVNC_vcenter">
<div id="noVNC_clipboard" class="noVNC_panel">
<div class="noVNC_heading">
<img src="{% static "js/novnc/app/images/clipboard.svg" %}"> Clipboard
</div>
<textarea id="noVNC_clipboard_text"rows=5></textarea>
<br />
<input id="noVNC_clipboard_clear_button" type="button" value="Clear" class="noVNC_submit" />
</div>
</div>
<!-- Toggle fullscreen -->
<input type="image" alt="Fullscreen" src="{% static "js/novnc/app/images/fullscreen.svg" %}"
id="noVNC_fullscreen_button" class="noVNC_button noVNC_hidden" title="Fullscreen" />
<!-- Settings -->
<input type="image" alt="Settings" src="{% static "js/novnc/app/images/settings.svg" %}"
id="noVNC_settings_button" class="noVNC_button" title="Settings" />
<div class="noVNC_vcenter">
<div id="noVNC_settings" class="noVNC_panel">
<ul>
<li class="noVNC_heading">
<img src="{% static "js/novnc/app/images/settings.svg" %}"> Settings
</li>
<li>
<label><input id="noVNC_setting_shared" type="checkbox" /> Shared Mode</label>
</li>
<li>
<label><input id="noVNC_setting_view_only" type="checkbox" /> View Only</label>
</li>
<li>
<hr>
</li>
<li>
<label><input id="noVNC_setting_view_clip" type="checkbox" /> Clip to Window</label>
</li>
<li>
<label for="noVNC_setting_resize">Scaling Mode:</label>
<select id="noVNC_setting_resize" name="vncResize">
<option value="off">None</option>
<option value="scale">Local Scaling</option>
<option value="remote">Remote Resizing</option>
</select>
</li>
<li>
<hr>
</li>
<li>
<div class="noVNC_expander">Advanced</div>
<div><ul>
<li>
<label for="noVNC_setting_quality">Quality:</label>
<input id="noVNC_setting_quality" type="range" min="0" max="9" value="6">
</li>
<li>
<label for="noVNC_setting_compression">Compression level:</label>
<input id="noVNC_setting_compression" type="range" min="0" max="9" value="2">
</li>
<li><hr></li>
<li>
<label for="noVNC_setting_repeaterID">Repeater ID:</label>
<input id="noVNC_setting_repeaterID" type="text" value="">
</li>
<li>
<div class="noVNC_expander">WebSocket</div>
<div><ul>
<li>
<label><input id="noVNC_setting_encrypt" type="checkbox"> Encrypt</label>
</li>
<li>
<label for="noVNC_setting_host">Host:</label>
<input id="noVNC_setting_host" type="text">
</li>
<li>
<label for="noVNC_setting_port">Port:</label>
<input id="noVNC_setting_port" type="number">
</li>
<li>
<label for="noVNC_setting_path">Path:</label>
<!-- <input id="noVNC_setting_path" type="text" value="websockify"> -->
<input id="noVNC_setting_path" type="text">
</li>
</ul></div>
</li>
<li><hr></li>
<li>
<label><input id="noVNC_setting_reconnect" type="checkbox"> Automatic Reconnect</label>
</li>
<li>
<label for="noVNC_setting_reconnect_delay">Reconnect Delay (ms):</label>
<input id="noVNC_setting_reconnect_delay" type="number">
</li>
<li><hr></li>
<li>
<label><input id="noVNC_setting_show_dot" type="checkbox"> Show Dot when No Cursor</label>
</li>
<li><hr></li>
<!-- Logging selection dropdown -->
<li>
<label>Logging:
<select id="noVNC_setting_logging" name="vncLogging">
</select>
</label>
</li>
</ul></div>
</li>
<li class="noVNC_version_separator"><hr></li>
<li class="noVNC_version_wrapper">
<span>Version:</span>
<span class="noVNC_version"></span>
</li>
</ul>
</div>
</div>
<!-- Connection Controls -->
<input type="image" alt="Disconnect" src="{% static "js/novnc/app/images/disconnect.svg" %}"
id="noVNC_disconnect_button" class="noVNC_button" title="Disconnect" />
</div>
</div>
</div> <!-- End of noVNC_control_bar -->
<div id="noVNC_hint_anchor" class="noVNC_vcenter">
<div id="noVNC_control_bar_hint">
</div>
</div>
<!-- Status Dialog -->
<div id="noVNC_status"></div>
<!-- Connect button -->
<div class="noVNC_center">
<div id="noVNC_connect_dlg">
<p class="noVNC_logo" translate="no"><span>no</span>VNC</p>
<div>
<button id="noVNC_connect_button">
<img alt="" src="{% static "js/novnc/app/images/connect.svg" %}"> {% trans 'Connect' %}
</button>
</div>
</div>
</div>
<!-- Server key verification dialog -->
<div class="noVNC_center noVNC_connect_layer">
<div id="noVNC_verify_server_dlg" class="noVNC_panel"><form>
<div class="noVNC_heading">
{% trans 'Server identity' %}
</div>
<div>
{% trans 'The server has provided the following identifying information:' %}
</div>
<div id="noVNC_fingerprint_block">
{% trans 'Fingerprint:' %}
<span id="noVNC_fingerprint"></span>
</div>
<div>
{% trans 'Please verify that the information is correct and press "Approve". Otherwise press "Reject".' %}
</div>
<div class="button_row">
<input id="noVNC_approve_server_button" type="submit" value="{% trans 'Approve' %}">
<input id="noVNC_reject_server_button" type="button" value="{% trans 'Reject' %}">
</div>
</form></div>
</div>
<!-- Password dialog -->
<div class="noVNC_center noVNC_connect_layer">
<div id="noVNC_credentials_dlg" class="noVNC_panel"><form>
<div class="noVNC_heading">
{% trans 'Credentials' %}
</div>
<div id="noVNC_username_block">
<label for="noVNC_username_input">{% trans 'Username' %}:</label>
<input id="noVNC_username_input">
</div>
<div id="noVNC_password_block">
<label for="noVNC_password_input">{% trans 'Password' %}:</label>
{% if perms.instances.passwordless_console %}
<input id="noVNC_password_input" type="password" value="{{ console_passwd|default_if_none:"" }}">
{% else %}
<input id="noVNC_password_input" type="password">
{% endif %}
</div>
<div class="button_row">
<input id="noVNC_credentials_button" type="submit" value="{% trans 'Send credentials' %}">
</div>
</form></div>
</div>
<!-- Transition Screens -->
<div id="noVNC_transition">
<div id="noVNC_transition_text"></div>
<div>
<input type="button" id="noVNC_cancel_reconnect_button" value="Cancel" class="noVNC_submit" />
</div>
<div class="noVNC_spinner"></div>
</div>
<!-- This is where the RFB elements will attach -->
<div id="noVNC_container">
<!-- Note that Google Chrome on Android doesn't respect any of these,
html attributes which attempt to disable text suggestions on the
on-screen keyboard. Let's hope Chrome implements the ime-mode
style for example -->
<textarea id="noVNC_keyboardinput" autocapitalize="off" autocomplete="off" spellcheck="false"
tabindex="-1"></textarea>
</div>
<audio id="noVNC_bell">
<source src="{% static "js/novnc/app/sounds/bell.oga" %}" type="audio/ogg">
<source src="{% static "js/novnc/app/sounds/bell.mp3" %}" type="audio/mpeg">
</audio>
{% endblock %}

View file

@ -1,207 +0,0 @@
{% extends "console-base.html" %}
{% load i18n %}
{% load static %}
{% block head %}
<!--
noVNC example: lightweight example using minimal UI and features
This is a self-contained file which doesn't import WebUtil or external CSS.
Copyright (C) 2019 The noVNC Authors
noVNC is licensed under the MPL 2.0 (see LICENSE.txt)
This file is licensed under the 2-Clause BSD license (see LICENSE.txt).
Connect parameters are provided in query string:
http://example.com/?scale=true (the proxy host, port and path come from the server)
-->
<!-- Stylesheets -->
<link rel="stylesheet" href='{% static "js/novnc/app/styles/lite.css" %}'>
<!-- actual script modules -->
<script type="module" crossorigin="anonymous">
// RFB holds the API to connect and communicate with a VNC server
import RFB from '{% static "js/novnc/core/rfb.js" %}';
import { bindKeyAction, bindSendKeys } from '{% static "js/console_keys.js" %}';
let rfb;
let desktopName;
// When this function is called we have
// successfully connected to a server
function connectedToServer(e) {
status("Connected to " + desktopName);
}
// This function is called when we are disconnected
function disconnectedFromServer(e) {
if (e.detail.clean) {
status("Disconnected");
} else {
status("Something went wrong, connection is closed");
}
}
// When this function is called, the server requires
// credentials to authenticate
function credentialsAreRequired(e) {
const password = prompt("Password Required:");
rfb.sendCredentials({ password: password });
}
// When this function is called we have received
// a desktop name from the server
function updateDesktopName(e) {
desktopName = e.detail.name;
}
function fullscreen() {
if (document.fullscreenElement || // alternative standard method
document.mozFullScreenElement || // currently working methods
document.webkitFullscreenElement ||
document.msFullscreenElement) {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
} else {
const el = document.documentElement;
const request = el.requestFullscreen ||
el.mozRequestFullScreen ||
el.webkitRequestFullscreen ||
el.msRequestFullscreen;
if (request) {
request.call(el).catch(() => {});
}
}
return false;
}
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
if ('keyboard' in navigator && typeof navigator.keyboard.lock === 'function') {
navigator.keyboard.lock(['AltLeft', 'AltRight', 'Tab', 'Escape']).catch(() => {});
}
} else {
if ('keyboard' in navigator && typeof navigator.keyboard.unlock === 'function') {
navigator.keyboard.unlock();
}
}
});
function machineShutdown() {
rfb.machineShutdown();
}
function machineReboot() {
rfb.machineReboot();
}
function machineReset() {
rfb.machineReset();
}
// Show a status text in the top bar
function status(text) {
document.getElementById('noVNC_status').textContent = text;
}
bindKeyAction('machineShutdownButton', machineShutdown);
bindKeyAction('machineRebootButton', machineReboot);
bindKeyAction('machineResetButton', machineReset);
bindKeyAction('fullscreen_button', fullscreen);
// fit the remote screen to the window, or show it 1:1 with scrollbars
bindKeyAction('scale_button', () => {
rfb.scaleViewport = !rfb.scaleViewport;
const link = document.querySelector('#scale_button a');
link.classList.toggle('active', rfb.scaleViewport);
link.setAttribute('aria-pressed', rfb.scaleViewport);
});
bindSendKeys(() => rfb);
// novncd behind the panel's own address (nginx's /novncd/), never a host
// from the link: the page sends it a console token
const host = window.location.hostname;
let port = window.location.port;
// the VNC password, for a user who may open the console without it;
// otherwise the server asks for it (credentialsrequired)
{% if perms.instances.passwordless_console and console_passwd %}
const credentials = { password: '{{ console_passwd|escapejs }}' };
{% else %}
const credentials = {};
{% endif %}
const path = 'novncd/';
// | | | | | |
// | | | Connect | | |
// v v v v v v
status("Connecting");
// Build the websocket URL used to connect
let url;
if (window.location.protocol === "https:") {
url = 'wss';
} else {
url = 'ws';
}
url += '://' + host;
if (port) {
url += ':' + port;
}
if (path) {
url += path.startsWith('/') ? path : ('/' + path);
} else {
url += '/';
}
const token = '{{ token|escapejs }}';
if (token) {
url += (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
}
// Creating a new RFB object will start a new connection
rfb = new RFB(document.getElementById('noVNC_container'), url,
{ credentials: credentials });
// Add listeners to important events from the RFB module
rfb.addEventListener("connect", connectedToServer);
rfb.addEventListener("disconnect", disconnectedFromServer);
rfb.addEventListener("credentialsrequired", credentialsAreRequired);
rfb.addEventListener("desktopname", updateDesktopName);
// Set parameters that can be changed on an active connection
rfb.scaleViewport = {{ scale|yesno:"true,false" }};
rfb.viewOnly = {{ view_only|yesno:"true,false" }};
rfb.resizeSession = {{ resize_session|yesno:"true,false" }};
rfb.clipViewport = {{ clip_viewport|yesno:"true,false" }};
</script>
{% endblock %}
{% block navbarmenu %}
{% include "console-menu.html" %}
<li class="nav-item" id="scale_button">
<a class="nav-link{% if scale %} active{% endif %}" href="#" role="button" aria-pressed="{{ scale|yesno:"true,false" }}">{% trans "Scale" %}</a>
</li>
{% endblock %}
{% block content %}
<div id="noVNC_status_bar">
<div id="noVNC_left_dummy_elem"></div>
<div id="noVNC_status">{% trans 'Loading' %}</div>
<div id="noVNC_buttons">
<input type="button" value="Send CtrlAltDel" id="sendCtrlAltDelButton" class="noVNC_shown">
<span id="noVNC_power_buttons" class="noVNC_hidden">
<input type="button" value="Shutdown" id="machineShutdownButton">
<input type="button" value="Reboot" id="machineRebootButton">
<input type="button" value="Reset" id="machineResetButton">
</span>
</div>
</div>
<div id="noVNC_container">
<!-- This is where the remote screen will appear -->
</div>
{% endblock %}

View file

@ -0,0 +1,121 @@
{% extends "console-base.html" %}
{% load i18n %}
{% load static %}
{% block head %}
<style>
#screen { flex: 1 1 auto; min-height: 0; overflow: hidden; }
</style>
{{ vnc|json_script:"vnc-config" }}
<script type="module" src="{% static "js/console_vnc.js" %}"></script>
{% endblock %}
{% block navbarstatus %}
<span id="status" class="badge text-bg-secondary" role="status"
data-connected="{% trans "Connected" %}" data-disconnected="{% trans "Disconnected" %}"
data-lost="{% trans "Connection lost" %}" data-failed="{% trans "Could not connect" %}"
data-refused="{% trans "Authentication failed" %}">{% trans "Connecting" %}</span>
{% endblock %}
{% block navbarmenu %}
{% include "console-menu.html" %}
<li class="nav-item">
<a class="nav-link" href="#" role="button" data-bs-toggle="modal" data-bs-target="#clipboard_dialog">{% trans "Clipboard" %}</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" id="viewMenuLink" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false">{% trans "View" %}</a>
<form class="dropdown-menu shadow p-3" id="view_menu" aria-labelledby="viewMenuLink" style="min-width: 280px;">
<label class="form-label" for="view_resize">{% trans "Scaling" %}</label>
<select class="form-select form-select-sm mb-3" id="view_resize">
<option value="off">{% trans "None" %}</option>
<option value="scale">{% trans "Fit to the window" %}</option>
<option value="remote">{% trans "Resize the VM's screen to the window" %}</option>
</select>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="view_clip">
<label class="form-check-label" for="view_clip">{% trans "Clip to the window" %}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="view_drag">
<label class="form-check-label" for="view_drag">{% trans "Drag to move a clipped screen" %}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="view_only">
<label class="form-check-label" for="view_only">{% trans "View only" %}</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="view_dot">
<label class="form-check-label" for="view_dot">{% trans "Show a dot when the VM hides the cursor" %}</label>
</div>
<label class="form-label" for="view_quality">{% trans "Quality" %}</label>
<input class="form-range" type="range" id="view_quality" min="0" max="9" value="6">
<label class="form-label" for="view_compression">{% trans "Compression" %}</label>
<input class="form-range" type="range" id="view_compression" min="0" max="9" value="2">
</form>
</li>
<li class="nav-item" id="keyboard_button" hidden>
<a class="nav-link" href="#" role="button">{% trans "Keyboard" %}</a>
</li>
{% endblock %}
{% block navbarend %}
<button type="button" class="btn btn-sm btn-light" id="connection_button"
data-reconnect="{% trans "Reconnect" %}">{% trans "Disconnect" %}</button>
{% endblock %}
{% block content %}
<div id="screen"></div>
<textarea id="keyboard_input" class="visually-hidden" autocapitalize="off" autocomplete="off"
spellcheck="false" tabindex="-1" aria-label="{% trans "Keyboard" %}"></textarea>
<div class="modal fade" id="credentials_dialog" tabindex="-1" aria-labelledby="credentials_title"
data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="credentials_form">
<div class="modal-header">
<h5 class="modal-title" id="credentials_title">{% trans "The console asks for its password" %}</h5>
</div>
<div class="modal-body">
<div class="mb-3" id="credentials_username_field" hidden>
<label class="form-label" for="credentials_username">{% trans "Username" %}</label>
<input class="form-control" id="credentials_username" autocomplete="off">
</div>
<label class="form-label" for="credentials_password">{% trans "Password" %}</label>
<input class="form-control" type="password" id="credentials_password" autocomplete="off">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="credentials_cancel">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-primary">{% trans "Connect" %}</button>
</div>
</form>
</div>
</div>
<div class="modal fade" id="clipboard_dialog" tabindex="-1" aria-labelledby="clipboard_title">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="clipboard_title">{% trans "Clipboard" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
<label class="form-label" for="clipboard_text">{% trans "Text" %}</label>
<textarea class="form-control font-monospace" id="clipboard_text" rows="6"></textarea>
<div class="form-text">
{% trans "Type it: the VM gets it key by key, also in its firmware, an installer or a login prompt. The VM's clipboard takes it only with clipboard support in the VM; what the VM copies shows here." %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="clipboard_send">{% trans "Send to the VM's clipboard" %}</button>
<button type="button" class="btn btn-primary" id="clipboard_type">{% trans "Type it" %}</button>
</div>
</div>
</div>
</div>
<audio id="bell" preload="none">
<source src="{% static "js/novnc/app/sounds/bell.oga" %}" type="audio/ogg">
<source src="{% static "js/novnc/app/sounds/bell.mp3" %}" type="audio/mpeg">
</audio>
{% endblock %}

View file

@ -1,17 +1,19 @@
# pylint: disable=no-member
import importlib.machinery
import json
import importlib.util
from pathlib import Path
from unittest.mock import MagicMock, patch
from accounts.models import UserInstance
from appsettings.models import AppSettings
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.signing import TimestampSigner
from django.conf import settings
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils.html import escapejs
from instances.models import Instance
from libvirt import libvirtError
from lxml import html
@ -28,6 +30,11 @@ novncd_mod = importlib.util.module_from_spec(_spec)
_loader.exec_module(novncd_mod)
def vnc_config(response):
"""What the VNC page's script starts from."""
return json.loads(html.fromstring(response.content).get_element_by_id("vnc-config").text)
class ConsoleViewsTestCase(TestCase):
@classmethod
def setUpTestData(cls):
@ -72,52 +79,25 @@ class ConsoleViewsTestCase(TestCase):
cls.url = reverse("console", args=[cls.compute.id, cls.instance.uuid])
@patch("console.views.wvmInstance")
def test_console_superuser_vnc_lite(self, mock_wvm):
def test_console_superuser_vnc(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_passwd.return_value = None
mock_conn.get_console_passwd.return_value = "vncsecret"
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
url = self.url + "?view=lite"
response = self.client.get(url)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "console-vnc-lite.html")
self.assertTemplateUsed(response, "console-vnc.html")
config = vnc_config(response)
signer = TimestampSigner(salt="console.novnc")
unsigned = signer.unsign(response.context["token"], max_age=120)
unsigned = signer.unsign(config["token"], max_age=120)
self.assertEqual(
unsigned, f"{self.compute.id}:{self.instance.uuid}:{self.admin_user.id}"
)
self.assertEqual(response.context["console_passwd"], "vncsecret")
@patch("console.views.wvmInstance")
def test_console_superuser_vnc_full(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
url = self.url + "?view=full"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "console-vnc-full.html")
# a VM with a QEMU websocket port goes through novncd too, on its VNC port
self.assertContains(response, "port: Number(window.location.port")
@patch("console.views.wvmInstance")
def test_the_lite_console_switches_scaling_from_its_menu(self, mock_wvm):
mock_wvm.return_value.get_console_type.return_value = "vnc"
self.client.force_login(self.admin_user)
for query, pressed in (("", "false"), ("&scale=true", "true")):
with self.subTest(query=query):
page = html.fromstring(self.client.get(self.url + f"?view=lite{query}").content)
switch = page.xpath("//*[@id='scale_button']//a")[0]
self.assertEqual(switch.get("aria-pressed"), pressed)
self.assertEqual("active" in switch.get("class").split(), pressed == "true")
page = html.fromstring(self.client.get(self.url + "?view=full").content)
self.assertEqual(page.xpath("//*[@id='scale_button']"), []) # noVNC's own settings scale the full view
self.assertEqual(config["password"], "vncsecret")
@patch("console.views.wvmInstance")
def test_console_unsupported_graphics_type(self, mock_wvm):
@ -127,8 +107,7 @@ class ConsoleViewsTestCase(TestCase):
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
url = self.url + "?view=full"
response = self.client.get(url)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "console-error.html")
@ -170,6 +149,7 @@ class ConsoleViewsTestCase(TestCase):
def test_console_user_assigned_instance_access(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_passwd.return_value = None
mock_wvm.return_value = mock_conn
self.client.force_login(self.alice)
@ -177,14 +157,14 @@ class ConsoleViewsTestCase(TestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "console-vnc-lite.html")
self.assertTemplateUsed(response, "console-vnc.html")
@patch("console.views.wvmInstance")
def test_the_console_password_reaches_the_page_only_with_passwordless_console(self, mock_wvm):
# without the permission noVNC asks for the password; the page must not hold it
# (letters and digits only: escapejs keeps them as they are in the lite view)
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_passwd.return_value = None
mock_conn.get_console_passwd.return_value = "vncpw7f3a"
mock_wvm.return_value = mock_conn
permission = Permission.objects.get(codename="passwordless_console")
@ -192,12 +172,11 @@ class ConsoleViewsTestCase(TestCase):
for user, holds in ((self.alice, False), (self.alice, True), (self.admin_user, True)):
if holds and not user.is_superuser:
user.user_permissions.add(permission)
for view in ("lite", "full"):
with self.subTest(user=user.username, permission=holds, view=view):
self.client.force_login(get_user_model().objects.get(pk=user.pk)) # fresh permission cache
response = self.client.get(self.url + f"?view={view}")
self.assertEqual(response.status_code, 200)
self.assertEqual("vncpw7f3a" in response.content.decode(), holds)
with self.subTest(user=user.username, permission=holds):
self.client.force_login(get_user_model().objects.get(pk=user.pk)) # fresh permission cache
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual("vncpw7f3a" in response.content.decode(), holds)
def test_console_unauthorized_user_forbidden(self):
self.client.force_login(self.bob)
@ -232,42 +211,73 @@ class ConsoleViewsTestCase(TestCase):
self.assertEqual(page.findtext(".//title"), "vm-alpha - Console")
@patch("console.views.wvmInstance")
def test_console_lite_uses_the_signed_token(self, mock_wvm):
def test_the_vnc_page_carries_the_signed_token(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_passwd.return_value = None
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
response = self.client.get(self.url)
signed = response.context["token"]
signed = vnc_config(response)["token"]
self.assertEqual(TimestampSigner(salt="console.novnc").unsign(signed),
f"{self.compute.id}:{self.instance.uuid}:{self.admin_user.id}")
self.assertContains(response, f"const token = '{escapejs(signed)}'")
@patch("console.views.wvmInstance")
def test_a_shut_off_vm_says_so(self, mock_wvm):
mock_wvm.return_value.get_console_type.return_value = "vnc"
mock_wvm.return_value.get_console_passwd.return_value = None
mock_wvm.return_value.instance.isActive.return_value = 0
self.client.force_login(self.alice)
response = self.client.get(self.url)
self.assertContains(response, "The VM is not running")
@patch("console.views.wvmInstance")
def test_the_lite_console_connects_only_to_the_servers_proxy(self, mock_wvm):
# a link naming another host would send it the signed console token
def test_the_vnc_page_connects_only_to_the_servers_proxy(self, mock_wvm):
# a link naming another host would send it the signed console token;
# nginx proxies /novncd/ on the panel's own scheme, host and port
mock_wvm.return_value.get_console_type.return_value = "vnc"
mock_wvm.return_value.get_console_passwd.return_value = None
self.client.force_login(self.alice)
response = self.client.get(self.url + "?host=attacker.example&port=443&path=x")
response = self.client.get(self.url + "?host=attacker.example&port=443&path=x&password=x")
self.assertNotContains(response, "attacker.example")
# nor a password from it: a link would leave it in history and logs
self.assertNotContains(response, "readQueryVariable")
self.assertContains(response, "const host = window.location.hostname;")
self.assertEqual(set(vnc_config(response)), {"token", "password", "resize", "clip", "viewOnly"})
script = (Path(settings.BASE_DIR) / "static/js/console_vnc.js").read_text()
self.assertIn("scheme + window.location.host + '/novncd/?token='", script)
@patch("console.views.wvmInstance")
def test_the_vnc_page_starts_as_the_console_settings_say(self, mock_wvm):
mock_wvm.return_value.get_console_type.return_value = "vnc"
mock_wvm.return_value.get_console_passwd.return_value = None
self.client.force_login(self.alice)
names = ("CONSOLE_SCALE", "CONSOLE_RESIZE_SESSION", "CONSOLE_CLIP_VIEWPORT", "CONSOLE_VIEW_ONLY")
for values, resize, clip, view_only in (
(("False", "False", "False", "False"), "off", False, False),
(("True", "False", "True", "True"), "scale", True, True),
(("True", "True", "False", "False"), "remote", False, False),
):
with self.subTest(values=values):
for name, value in zip(names, values):
AppSettings.objects.filter(key=name).update(value=value)
config = vnc_config(self.client.get(self.url))
self.assertEqual((config["resize"], config["clip"], config["viewOnly"]), (resize, clip, view_only))
@patch("console.views.wvmInstance")
def test_the_vnc_password_stays_data_in_the_page(self, mock_wvm):
# json_script: no quote or tag in it ends the script element
mock_wvm.return_value.get_console_type.return_value = "vnc"
mock_wvm.return_value.get_console_passwd.return_value = None
mock_wvm.return_value.get_console_passwd.return_value = "O'B</script><script>x()&"
self.client.force_login(self.admin_user)
response = self.client.get(self.url)
self.assertNotContains(response, "</script><script>x()")
self.assertEqual(vnc_config(response)["password"], "O'B</script><script>x()&")
@patch("console.views.wvmInstance")
def test_the_page_sets_no_token_cookie(self, mock_wvm):
# one cookie for every tab would name the last console opened
mock_wvm.return_value.get_console_type.return_value = "vnc"
mock_wvm.return_value.get_console_passwd.return_value = None
self.client.force_login(self.admin_user)
response = self.client.get(self.url)
self.assertNotIn("token", response.cookies)
@ -278,6 +288,7 @@ class ConsoleViewsTestCase(TestCase):
# is for superusers and VM owners only.
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_passwd.return_value = None
mock_wvm.return_value = mock_conn
perm = Permission.objects.get(codename="view_instances")
@ -288,7 +299,7 @@ class ConsoleViewsTestCase(TestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
self.assertTemplateNotUsed(response, "console-vnc-lite.html")
self.assertTemplateNotUsed(response, "console-vnc.html")
@patch("console.views.wvmInstance")
def test_a_tcp_hosts_loopback_console_says_why_it_cannot_open(self, mock_wvm):
@ -296,6 +307,7 @@ class ConsoleViewsTestCase(TestCase):
Compute.objects.filter(pk=self.compute.pk).update(type=1)
conn = mock_wvm.return_value
conn.get_console_type.return_value = "vnc"
conn.get_console_passwd.return_value = None
conn.get_console_socket.return_value = None
conn.console_listens_locally.return_value = False # next start: all interfaces
self.client.force_login(self.admin_user)
@ -305,108 +317,6 @@ class ConsoleViewsTestCase(TestCase):
response = self.client.get(self.url)
self.assertEqual("This console listens only on its host" in response.content.decode(), refused)
@patch("console.views.wvmInstance")
def test_both_pages_connect_to_novncd_behind_the_panels_own_address(self, mock_wvm):
# nginx proxies /novncd/ on the panel's own scheme, host and port: no
# console address to configure, HTTPS gives wss
mock_wvm.return_value.get_console_type.return_value = "vnc"
self.client.force_login(self.admin_user)
lite = self.client.get(self.url + "?view=lite")
self.assertContains(lite, "const host = window.location.hostname;")
self.assertContains(lite, "const path = 'novncd/';")
full = self.client.get(self.url + "?view=full")
self.assertContains(full, "host: window.location.hostname,")
self.assertContains(full, "encrypt: https,")
@patch("console.views.wvmInstance")
def test_console_xss_protection_query_parameters(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
# Attempt XSS injection via query parameters
url = (
self.url
+ "?view=lite&view_only=alert('xss')&scale=true&clip_viewport=1"
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["view_only"], False)
self.assertEqual(response.context["scale"], True)
self.assertEqual(response.context["clip_viewport"], True)
content = response.content.decode("utf-8")
self.assertNotIn("alert('xss')", content)
self.assertIn("rfb.viewOnly = false;", content)
self.assertIn("rfb.scaleViewport = true;", content)
@patch("console.views.wvmInstance")
def test_console_password_escapejs_escaping(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_passwd.return_value = "O'Brian<script>&123"
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
# Test Lite mode
url_lite = self.url + "?view=lite"
resp_lite = self.client.get(url_lite)
self.assertEqual(resp_lite.status_code, 200)
content_lite = resp_lite.content.decode("utf-8")
self.assertIn(r"O\u0027Brian", content_lite)
self.assertNotIn("&#39;", content_lite)
# Test Full mode
url_full = self.url + "?view=full"
resp_full = self.client.get(url_full)
self.assertEqual(resp_full.status_code, 200)
content_full = resp_full.content.decode("utf-8")
# In script tag, password must use JS unicode escape and not HTML entities
main_script_block = [
s.split("</script>")[0]
for s in content_full.split("<script")
if "defaults" in s
][0]
self.assertIn(r"password: 'O\u0027Brian", main_script_block)
self.assertNotIn("&#39;", main_script_block)
self.assertNotIn("&#x27;", main_script_block)
@patch("console.views.wvmInstance")
def test_console_full_resize_syntax_validity(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
# Default: scale=False, resize_session=False -> 'off'
url = self.url + "?view=full"
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
content = resp.content.decode("utf-8")
self.assertIn("resize: 'off',", content)
self.assertNotIn("&#x27;off&#x27;", content)
self.assertNotIn("&#39;off&#39;", content)
# scale=True, resize_session=False -> 'scale'
url_scale = self.url + "?view=full&scale=true"
resp_scale = self.client.get(url_scale)
self.assertEqual(resp_scale.status_code, 200)
content_scale = resp_scale.content.decode("utf-8")
self.assertIn("resize: 'scale',", content_scale)
# resize_session=True -> 'remote'
url_remote = (
self.url + "?view=full&resize_session=true"
)
resp_remote = self.client.get(url_remote)
self.assertEqual(resp_remote.status_code, 200)
content_remote = resp_remote.content.decode("utf-8")
self.assertIn("resize: 'remote',", content_remote)
@patch("console.views.wvmInstance")
def test_console_libvirt_error_fallback(self, mock_wvm):
mock_wvm.side_effect = libvirtError("Connection to libvirtd failed")
@ -441,11 +351,11 @@ class ConsoleViewsTestCase(TestCase):
def test_console_send_keys_menu_elements(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_passwd.return_value = None
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
url = self.url + "?view=lite"
response = self.client.get(url)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf-8")
@ -460,6 +370,9 @@ class ConsoleViewsTestCase(TestCase):
"ctrlz",
"ctrld",
"ctrlbackslash",
"holdctrl",
"holdalt",
"holdsuper",
] + [f"ctrlaltf{i}" for i in range(1, 13)]
for key_id in expected_ids:

View file

@ -13,22 +13,9 @@ from vrtManager.instance import wvmInstance
from vrtManager.util import is_loopback
def to_bool(val, default=False):
if val is None:
return default
return str(val).strip().lower() in ("true", "1", "yes")
def console(request, compute_id, uuid):
"""The console page of the VM with this UUID on this compute. The page's
query gives the view (lite or full) and its display options. A console
"""The console page of the VM with this UUID on this compute. A console
that cannot open gets a page that says why, and no console client."""
view_type = "full" if request.GET.get("view", "").strip().lower() == "full" else "lite"
view_only = to_bool(request.GET.get("view_only"), default=to_bool(app_settings.CONSOLE_VIEW_ONLY))
scale = to_bool(request.GET.get("scale"), default=to_bool(app_settings.CONSOLE_SCALE))
resize_session = to_bool(request.GET.get("resize_session"), default=to_bool(app_settings.CONSOLE_RESIZE_SESSION))
clip_viewport = to_bool(request.GET.get("clip_viewport"), default=to_bool(app_settings.CONSOLE_CLIP_VIEWPORT))
instance = Instance.objects.filter(compute_id=compute_id, uuid=uuid).first()
# Same answer for "no such VM" and "not allowed", so the response
# does not reveal whether a VM exists.
@ -84,5 +71,22 @@ def console(request, compute_id, uuid):
# the console daemons' credential for this page
token = tokens.SIGNER.sign(f"{compute_id}:{uuid}:{request.user.id}")
console_page = "console-xterm.html" if console_type == "pty" else "console-vnc-" + view_type + ".html"
return render(request, console_page, locals())
if console_type == "pty":
return render(request, "console-xterm.html", {"instance": instance, "token": token})
# how the VNC page starts (Settings > Console); its View menu changes them
if app_settings.CONSOLE_RESIZE_SESSION == "True":
resize = "remote"
elif app_settings.CONSOLE_SCALE == "True":
resize = "scale"
else:
resize = "off"
vnc = {
"token": token,
# without the permission the VNC server asks the user for it
"password": console_passwd if request.user.has_perm("instances.passwordless_console") else None,
"resize": resize,
"clip": app_settings.CONSOLE_CLIP_VIEWPORT == "True",
"viewOnly": app_settings.CONSOLE_VIEW_ONLY == "True",
}
return render(request, "console-vnc.html", {"instance": instance, "vnc": vnc})

View file

@ -59,8 +59,8 @@
{% endif %}
{% if "console" in actions %}
<button class="btn btn-sm btn-secondary" type="button" data-url="{% url 'console' instance.compute_id instance.uuid %}"
onclick="window.open(this.dataset.url, '', 'width=850,height=650')"
<button class="btn btn-sm btn-secondary" type="button" data-url="{% url 'console' instance.compute_id instance.uuid %}" data-window="console-{{ instance.uuid }}"
onclick="window.open(this.dataset.url, this.dataset.window, 'width=850,height=650')"
title="{% trans "Console" %}">{% bs_icon 'eye-fill' %}</button>
{% else %}
<button class="btn btn-sm btn-secondary disabled" title="{% trans "VNC Console" %}" disabled>{% bs_icon 'eye-fill' %}</button>

View file

@ -37,49 +37,11 @@
{% if not can_open_console %}
<p>{% trans "Only the owners of this instance and administrators can open its console." %}</p>
{% else %}
{% if instance.console_type == 'vnc' %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="scale"
{% if app_settings.CONSOLE_SCALE == 'True' %} checked {% endif %}
id="scale">
<label class="form-check-label" for="scale">{% trans "Scale" %}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="view_only"
{% if app_settings.CONSOLE_VIEW_ONLY == 'True' %} checked {% endif %}
id="view_only">
<label class="form-check-label" for="view_only">{% trans "View Only" %}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="resize_session"
{% if app_settings.CONSOLE_RESIZE_SESSION == 'True' %} checked {% endif %}
id="resize_session">
<label class="form-check-label" for="resize_session">{% trans "Resize Session" %}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="clip_viewport"
{% if app_settings.CONSOLE_CLIP_VIEWPORT == 'True' %} checked {% endif %}
id="clip_viewport">
<label class="form-check-label" for="clip_viewport">{% trans "Clip viewport" %}</label>
</div>
{% endif %}
{% if instance.status == 1 %}
<!-- Split button -->
<div class="btn-group float-end" role="group">
<button type="button" id="consoleBtnGroup" class="btn btn-lg btn-success"
onclick="open_console('lite')">{% trans 'Console' %}</button>
<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="#"
title="{% trans "Console port" %}: {{ instance.console_port }}"
onclick="open_console('lite')">{% trans "Console" %} - {% trans "Lite" %}</a>
<a class="dropdown-item" href="#"
title="{% trans "Console port" %}: {{ instance.console_port }}"
onclick="open_console('full')">{% trans "Console" %} - {% trans "Full" %}</a>
</div>
</div>
{# one window per VM: opening it again reloads it #}
<button type="button" class="btn btn-lg btn-success float-end" id="console_button"
data-url="{% url 'console' compute.id instance.uuid %}" data-window="console-{{ instance.uuid }}"
onclick="window.open(this.dataset.url, this.dataset.window, 'width=850,height=650')">{% trans "Console" %}</button>
{% else %}
<button class="btn btn-lg btn-success float-end disabled">{% trans "Console" %}</button>
{% endif %}
@ -153,17 +115,3 @@
{% endif %}
</div>
</div>
{% block script %}
{% if can_open_console %}
<script>
function open_console(view_style) {
const sc = $("#scale").is(':checked');
const vo = $("#view_only").is(':checked');
const rs = $("#resize_session").is(':checked');
const cv = $("#clip_viewport").is(':checked');
const options = new URLSearchParams({view: view_style, view_only: vo, scale: sc, clip_viewport: cv, resize_session: rs});
window.open('{% url 'console' compute.id instance.uuid %}?' + options, '', 'width=850,height=600')
}
</script>
{% endif %}
{% endblock %}

View file

@ -84,7 +84,7 @@ class PageControls:
return bool(doc.xpath("//form[@action=$a]", a=reverse(f"instances:{name}", args=[pk])))
found = {
"console": bool(doc.xpath("//button[@id='consoleBtnGroup']")),
"console": bool(doc.xpath("//button[@id='console_button']")),
"vv file": bool(doc.xpath("//a[@href=$a]", a=reverse("instances:getvvfile", args=[pk]))),
"vdi": bool(doc.xpath("//*[@id='vdiconsole']")),
"power": form("poweroff") or form("poweron"),

View file

@ -1,13 +1,32 @@
// The keys menu of the VNC console pages (lite and full): key combinations
// the browser or the operating system would otherwise catch.
// The keys menu of the VNC console page: key combinations the browser or the
// operating system would otherwise catch, and modifiers a device lacks.
import KeyTable from './novnc/core/input/keysym.js';
const CTRL = { keysym: KeyTable.XK_Control_L, code: 'ControlLeft' };
const ALT = { keysym: KeyTable.XK_Alt_L, code: 'AltLeft' };
const SUPER = { keysym: KeyTable.XK_Super_L, code: 'MetaLeft' };
const HOLDS = [['holdctrl', CTRL], ['holdalt', ALT], ['holdsuper', SUPER]];
function hold(rfb, id, key, down) {
const item = document.getElementById(id);
rfb.sendKey(key.keysym, key.code, down);
item.setAttribute('aria-pressed', down);
item.classList.toggle('active', down);
}
// the held modifiers up: before a combination from the menu (which presses
// and releases its own), and before the page stops sending keys
export function releaseHeld(rfb) {
for (const [id, key] of HOLDS) {
if (document.getElementById(id).getAttribute('aria-pressed') === 'true') {
hold(rfb, id, key, false);
}
}
}
// press the keys in order, release them in reverse
function sendKeys(rfb, keys) {
if (!rfb || (rfb._rfbConnectionState && rfb._rfbConnectionState !== 'connected')) { return; }
releaseHeld(rfb);
keys.forEach((key) => rfb.sendKey(key.keysym, key.code, true));
setTimeout(() => [...keys].reverse().forEach((key) => rfb.sendKey(key.keysym, key.code, false)), 20);
}
@ -22,20 +41,15 @@ export function bindKeyAction(id, handler) {
}
}
// getRfb: the page's RFB object, which the full page creates on connect
export function bindSendKeys(getRfb) {
const send = (...keys) => () => sendKeys(getRfb(), keys);
const ctrlAltDel = () => {
const rfb = getRfb();
if (rfb && typeof rfb.sendCtrlAltDel === 'function') {
rfb.sendCtrlAltDel();
}
};
bindKeyAction('ctrlaltdel', ctrlAltDel);
bindKeyAction('sendCtrlAltDelButton', ctrlAltDel);
export function bindSendKeys(rfb) {
const send = (...keys) => () => sendKeys(rfb, keys);
bindKeyAction('ctrlaltdel', () => {
releaseHeld(rfb);
rfb.sendCtrlAltDel();
});
bindKeyAction('ctrlaltbackspace', send(CTRL, ALT, { keysym: KeyTable.XK_BackSpace, code: 'Backspace' }));
bindKeyAction('alttab', send(ALT, { keysym: KeyTable.XK_Tab, code: 'Tab' }));
bindKeyAction('superkey', send({ keysym: KeyTable.XK_Super_L, code: 'MetaLeft' }));
bindKeyAction('superkey', send(SUPER));
bindKeyAction('sendesc', send({ keysym: KeyTable.XK_Escape, code: 'Escape' }));
bindKeyAction('sendtab', send({ keysym: KeyTable.XK_Tab, code: 'Tab' }));
bindKeyAction('ctrlc', send(CTRL, { keysym: KeyTable.XK_c, code: 'KeyC' }));
@ -45,4 +59,41 @@ export function bindSendKeys(getRfb) {
for (let i = 1; i <= 12; i++) {
bindKeyAction('ctrlaltf' + i, send(CTRL, ALT, { keysym: KeyTable['XK_F' + i], code: 'F' + i }));
}
// a held modifier stays down until it is chosen again: with the next key
// typed it makes a combination the menu does not list
for (const [id, key] of HOLDS) {
bindKeyAction(id, () => {
// a view-only page sends no keys: nothing to hold
if (!rfb.viewOnly) {
hold(rfb, id, key, document.getElementById(id).getAttribute('aria-pressed') !== 'true');
}
});
}
}
// the Fullscreen item; in full screen the page also takes the keys the
// browser would keep (Alt, Tab, Escape) where the browser lets it
export function bindFullscreen() {
const item = document.getElementById('fullscreen_button');
if (!document.fullscreenEnabled) {
item.hidden = true;
return;
}
bindKeyAction('fullscreen_button', () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen().catch(() => {});
}
});
document.addEventListener('fullscreenchange', () => {
if (!navigator.keyboard) {
return;
}
if (document.fullscreenElement) {
navigator.keyboard.lock(['AltLeft', 'AltRight', 'Tab', 'Escape']).catch(() => {});
} else {
navigator.keyboard.unlock();
}
});
}

222
static/js/console_vnc.js Normal file
View file

@ -0,0 +1,222 @@
// The VNC console page: noVNC's RFB in the page's screen, driven from the
// panel's bar: status, keys, clipboard, view, touch keyboard, reconnect.
import RFB from './novnc/core/rfb.js';
import Keyboard from './novnc/core/input/keyboard.js';
import KeyTable from './novnc/core/input/keysym.js';
import keysyms from './novnc/core/input/keysymdef.js';
import { isTouchDevice } from './novnc/core/util/browser.js';
import { bindFullscreen, bindKeyAction, bindSendKeys, releaseHeld } from './console_keys.js';
const config = JSON.parse(document.getElementById('vnc-config').textContent);
const status = document.getElementById('status');
const connectionButton = document.getElementById('connection_button');
// novncd behind the panel's own address (nginx's /novncd/), never a host from
// the link; the page's token is its credential
const scheme = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const url = scheme + window.location.host + '/novncd/?token=' + encodeURIComponent(config.token);
const rfb = new RFB(document.getElementById('screen'), url,
{ credentials: config.password ? { password: config.password } : {} });
// ---- status and reconnect
let connected = false;
let closed = false;
let refusal = null;
function showStatus(text, color) {
status.textContent = text;
status.className = 'badge text-bg-' + color;
}
rfb.addEventListener('connect', () => {
connected = true;
showStatus(status.dataset.connected, 'success');
rfb.focus();
});
// the server's own words (untranslated) on hover
rfb.addEventListener('securityfailure', (e) => {
refusal = status.dataset.refused;
status.title = (e.detail.reason || '').replace(/\0/g, '');
});
rfb.addEventListener('disconnect', (e) => {
closed = true;
if (refusal) {
showStatus(refusal, 'danger');
} else if (!connected) {
showStatus(status.dataset.failed, 'danger');
} else {
showStatus(e.detail.clean ? status.dataset.disconnected : status.dataset.lost, 'danger');
}
connectionButton.textContent = connectionButton.dataset.reconnect;
credentialsDialog.hide();
});
// the token is good for one connection soon after the page loads: a new
// connection takes a new page
connectionButton.addEventListener('click', () => {
if (closed) {
window.location.reload();
} else {
rfb.disconnect();
}
});
// ---- the VNC password, when the server asks for it
const credentialsDialog = new bootstrap.Modal('#credentials_dialog');
const username = document.getElementById('credentials_username');
const password = document.getElementById('credentials_password');
rfb.addEventListener('credentialsrequired', (e) => {
document.getElementById('credentials_username_field').hidden = !e.detail.types.includes('username');
credentialsDialog.show();
});
document.getElementById('credentials_dialog').addEventListener('shown.bs.modal', () => {
// closed while it was opening: a dialog still opening ignores hide()
if (closed) {
credentialsDialog.hide();
return;
}
(username.offsetParent ? username : password).focus();
});
document.getElementById('credentials_form').addEventListener('submit', (e) => {
e.preventDefault();
const credentials = { password: password.value };
if (username.offsetParent) {
credentials.username = username.value;
}
password.value = '';
credentialsDialog.hide();
rfb.sendCredentials(credentials);
});
bindKeyAction('credentials_cancel', () => {
credentialsDialog.hide();
rfb.disconnect();
});
// ---- keys, clipboard and typed text
bindSendKeys(rfb);
bindFullscreen();
// the characters a US keyboard types with Shift: the VM gets keys, and its
// key for "A" is the key for "a"
const SHIFTED = /[A-Z~!@#$%^&*()_+{}|:"<>?]/;
const typedKeys = {
'\n': [KeyTable.XK_Return, 'Enter'],
'\b': [KeyTable.XK_BackSpace, 'Backspace'],
'\t': [KeyTable.XK_Tab, 'Tab'],
};
async function typeCharacter(character) {
// a typist's pace: firmware drops keys that come faster
await new Promise((resolve) => setTimeout(resolve, 25));
const [keysym, code] = typedKeys[character] || [keysyms.lookup(character.codePointAt(0)), null];
if (SHIFTED.test(character)) {
rfb.sendKey(KeyTable.XK_Shift_L, 'ShiftLeft', true);
rfb.sendKey(keysym, code);
rfb.sendKey(KeyTable.XK_Shift_L, 'ShiftLeft', false);
} else {
rfb.sendKey(keysym, code);
}
}
// key by key, after what is still being typed: works where no clipboard does
// (firmware, installers, login prompts); "\b" is a Backspace
let typing = Promise.resolve();
function typeText(text) {
for (const character of text.replace(/\r\n?/g, '\n')) {
typing = typing.then(() => typeCharacter(character));
}
}
const clipboardText = document.getElementById('clipboard_text');
const clipboardDialog = bootstrap.Modal.getOrCreateInstance('#clipboard_dialog');
rfb.addEventListener('clipboard', (e) => {
clipboardText.value = e.detail.text;
});
bindKeyAction('clipboard_send', () => {
rfb.clipboardPasteFrom(clipboardText.value);
clipboardDialog.hide();
});
bindKeyAction('clipboard_type', () => {
clipboardDialog.hide();
typeText(clipboardText.value);
});
const bell = document.getElementById('bell');
rfb.addEventListener('bell', () => bell.play().catch(() => {}));
// back to the VM's screen after a menu or a dialog
document.addEventListener('hidden.bs.dropdown', () => rfb.focus());
document.addEventListener('hidden.bs.modal', () => rfb.focus());
// ---- the View menu
const resize = document.getElementById('view_resize');
const clip = document.getElementById('view_clip');
const drag = document.getElementById('view_drag');
const viewOnly = document.getElementById('view_only');
const dot = document.getElementById('view_dot');
const quality = document.getElementById('view_quality');
const compression = document.getElementById('view_compression');
function applyView() {
rfb.scaleViewport = resize.value === 'scale';
rfb.resizeSession = resize.value === 'remote';
// a screen fitted to the window has nothing to clip, an unclipped one nothing to drag
clip.disabled = rfb.scaleViewport;
rfb.clipViewport = clip.checked && !rfb.scaleViewport;
drag.disabled = !rfb.clipViewport;
rfb.dragViewport = drag.checked && rfb.clipViewport;
if (viewOnly.checked) {
releaseHeld(rfb); // while it still sends keys
}
rfb.viewOnly = viewOnly.checked;
rfb.showDotCursor = dot.checked;
rfb.qualityLevel = Number(quality.value);
rfb.compressionLevel = Number(compression.value);
}
// a touch screen cannot scroll a screen larger than the window: it starts fitted
resize.value = isTouchDevice && config.resize === 'off' ? 'scale' : config.resize;
clip.checked = config.clip;
viewOnly.checked = config.viewOnly;
applyView();
document.getElementById('view_menu').addEventListener('change', applyView);
// ---- the on-screen keyboard of a touch device
if (isTouchDevice) {
const keyboardInput = document.getElementById('keyboard_input');
document.getElementById('keyboard_button').hidden = false;
bindKeyAction('keyboard_button', () => keyboardInput.focus());
keyboardInput.addEventListener('focus', () => { rfb.focusOnClick = false; });
keyboardInput.addEventListener('blur', () => { rfb.focusOnClick = true; });
// the keys it reports as key events
const keyboard = new Keyboard(keyboardInput);
keyboard.onkeyevent = (keysym, code, down) => rfb.sendKey(keysym, code, down);
keyboard.grab();
// the text it reports only as input (Android): what changed since the
// last input, as Backspaces and characters; the field always holds text
// for a Backspace to delete
let lastText = '';
const resetInput = () => { keyboardInput.value = lastText = '_'.repeat(100); };
resetInput();
keyboardInput.addEventListener('input', () => {
const text = keyboardInput.value;
let same = 0;
while (same < Math.min(text.length, lastText.length) && text[same] === lastText[same]) {
same++;
}
typeText('\b'.repeat(lastText.length - same) + text.slice(same));
if (text.length === 0 || text.length > 200) {
resetInput();
} else {
lastText = text;
}
});
}