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:
parent
3a560e9f85
commit
0532471759
12 changed files with 520 additions and 843 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
121
console/templates/console-vnc.html
Normal file
121
console/templates/console-vnc.html
Normal 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 %}
|
||||
|
|
@ -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("'", 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("'", main_script_block)
|
||||
self.assertNotIn("'", 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("'off'", content)
|
||||
self.assertNotIn("'off'", 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:
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
222
static/js/console_vnc.js
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue