noVNC可以給linux系統提供基於VNC虛擬桌面的WEB服務,這使得openstack使用noVNC對外提供虛擬機的WEB版虛擬桌面。
不過用這個noVNC也有一些問題,在使用HTML2canvas截圖或者使用一些需要外部操控的操作就出問題。
問題重現GIF如下:
經查,HTML2canvas這個js控件的工作原理是讀取HTML元素,但是noVNC或openstack提供的noVNC窗口url都是與現在用的系統不同域(簡單來說這些服務就是運行在不同的機子上),這一步因為headers不支持跨域的問題失敗了——下載的截圖noVNC畫面部分為空白,鍵入F12查看控制台,顯示Uncaught SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.
網上很多解決方案,無一不是在服務端修改配置、修改headers、修改HTML2canvas參數使被跨域的服務支持跨域,但是畫面截圖這個功能,發起者是外部系統,跨的域是noVNC或openstack的ip和端口,沒有支持跨域的配置,又不能隨意修改里面的代碼,修改HTML2canvas參數也無效。
這時就有一個想法:既然noVNC的窗口本質上就是一堆HTML代碼,是否可以將代碼直接貼在本系統上?
noVNC窗口代碼

<!DOCTYPE html> <html> <head> <!-- noVNC example: lightweight example using minimal UI and features Copyright (C) 2012 Joel Martin Copyright (C) 2017 Samuel Mannehed for Cendio AB 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 --> <title>noVNC</title> <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,chrome=1"> <!-- Icons (see Makefile for what the sizes are for) --> <link rel="icon" sizes="16x16" type="image/png" href="app/images/icons/novnc-16x16.png"> <link rel="icon" sizes="24x24" type="image/png" href="app/images/icons/novnc-24x24.png"> <link rel="icon" sizes="32x32" type="image/png" href="app/images/icons/novnc-32x32.png"> <link rel="icon" sizes="48x48" type="image/png" href="app/images/icons/novnc-48x48.png"> <link rel="icon" sizes="60x60" type="image/png" href="app/images/icons/novnc-60x60.png"> <link rel="icon" sizes="64x64" type="image/png" href="app/images/icons/novnc-64x64.png"> <link rel="icon" sizes="72x72" type="image/png" href="app/images/icons/novnc-72x72.png"> <link rel="icon" sizes="76x76" type="image/png" href="app/images/icons/novnc-76x76.png"> <link rel="icon" sizes="96x96" type="image/png" href="app/images/icons/novnc-96x96.png"> <link rel="icon" sizes="120x120" type="image/png" href="app/images/icons/novnc-120x120.png"> <link rel="icon" sizes="144x144" type="image/png" href="app/images/icons/novnc-144x144.png"> <link rel="icon" sizes="152x152" type="image/png" href="app/images/icons/novnc-152x152.png"> <link rel="icon" sizes="192x192" type="image/png" href="app/images/icons/novnc-192x192.png"> <!-- Firefox currently mishandles SVG, see #1419039 <link rel="icon" sizes="any" type="image/svg+xml" href="app/images/icons/novnc-icon.svg"> --> <!-- Repeated last so that legacy handling will pick this --> <link rel="icon" sizes="16x16" type="image/png" href="app/images/icons/novnc-16x16.png"> <!-- 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" /> <!-- Home Screen Icons (favourites and bookmarks use the normal icons) --> <link rel="apple-touch-icon" sizes="60x60" type="image/png" href="app/images/icons/novnc-60x60.png"> <link rel="apple-touch-icon" sizes="76x76" type="image/png" href="app/images/icons/novnc-76x76.png"> <link rel="apple-touch-icon" sizes="120x120" type="image/png" href="app/images/icons/novnc-120x120.png"> <link rel="apple-touch-icon" sizes="152x152" type="image/png" href="app/images/icons/novnc-152x152.png"> <!-- Stylesheets --> <link rel="stylesheet" href="app/styles/lite.css"> <!-- <script type='text/javascript' src='http://getfirebug.com/releases/lite/1.2/firebug-lite-compressed.js'></script> --> <!-- promise polyfills promises for IE11 --> <script src="vendor/promise.js"></script> <!-- ES2015/ES6 modules polyfill --> <script type="module"> window._noVNC_has_module_support = true; </script> <script> window.addEventListener("load", function() { if (window._noVNC_has_module_support) return; var loader = document.createElement("script"); loader.src = "vendor/browser-es-module-loader/dist/browser-es-module-loader.js"; document.head.appendChild(loader); }); </script> <!-- actual script modules --> <script type="module" crossorigin="anonymous"> // Load supporting scripts import * as WebUtil from './app/webutil.js'; import RFB from './core/rfb.js'; var rfb; var desktopName; function updateDesktopName(e) { desktopName = e.detail.name; } function credentials(e) { var html; var form = document.createElement('form'); form.innerHTML = '<label></label>'; form.innerHTML += '<input type=password size=10 id="password_input">'; form.onsubmit = setPassword; // bypass status() because it sets text content document.getElementById('noVNC_status_bar').setAttribute("class", "noVNC_status_warn"); document.getElementById('noVNC_status').innerHTML = ''; document.getElementById('noVNC_status').appendChild(form); document.getElementById('noVNC_status').querySelector('label').textContent = 'Password Required: '; } function setPassword() { rfb.sendCredentials({ password: document.getElementById('password_input').value }); return false; } function sendCtrlAltDel() { rfb.sendCtrlAltDel(); return false; } function machineShutdown() { rfb.machineShutdown(); return false; } function machineReboot() { rfb.machineReboot(); return false; } function machineReset() { rfb.machineReset(); return false; } function status(text, level) { switch (level) { case 'normal': case 'warn': case 'error': break; default: level = "warn"; } document.getElementById('noVNC_status_bar').className = "noVNC_status_" + level; document.getElementById('noVNC_status').textContent = text; } function connected(e) { document.getElementById('sendCtrlAltDelButton').disabled = false; if (WebUtil.getConfigVar('encrypt', (window.location.protocol === "https:"))) { status("Connected (encrypted) to " + desktopName, "normal"); } else { status("Connected (unencrypted) to " + desktopName, "normal"); } } function disconnected(e) { document.getElementById('sendCtrlAltDelButton').disabled = true; updatePowerButtons(); if (e.detail.clean) { status("Disconnected", "normal"); } else { status("Something went wrong, connection is closed", "error"); } } function updatePowerButtons() { var powerbuttons; powerbuttons = document.getElementById('noVNC_power_buttons'); if (rfb.capabilities.power) { powerbuttons.className= "noVNC_shown"; } else { powerbuttons.className = "noVNC_hidden"; } } document.getElementById('sendCtrlAltDelButton').onclick = sendCtrlAltDel; document.getElementById('machineShutdownButton').onclick = machineShutdown; document.getElementById('machineRebootButton').onclick = machineReboot; document.getElementById('machineResetButton').onclick = machineReset; WebUtil.init_logging(WebUtil.getConfigVar('logging', 'warn')); document.title = WebUtil.getConfigVar('title', 'noVNC'); // By default, use the host and port of server that served this file var host = WebUtil.getConfigVar('host', window.location.hostname); var port = WebUtil.getConfigVar('port', window.location.port); // if port == 80 (or 443) then it won't be present and should be // set manually if (!port) { if (window.location.protocol.substring(0,5) == 'https') { port = 443; } else if (window.location.protocol.substring(0,4) == 'http') { port = 80; } } var password = WebUtil.getConfigVar('password', ''); //這里還有個問題,每次進入這個窗口都要輸密碼,那這里是不是可以直接輸對密碼直接通過 var path = WebUtil.getConfigVar('path', 'websockify'); // If a token variable is passed in, set the parameter in a cookie. // This is used by nova-novncproxy. var token = WebUtil.getConfigVar('token', null); if (token) { // if token is already present in the path we should use it path = WebUtil.injectParamIfMissing(path, "token", token); WebUtil.createCookie('token', token, 1) } (function() { status("Connecting", "normal"); if ((!host) || (!port)) { status('Must specify host and port in URL', 'error'); } var url; if (WebUtil.getConfigVar('encrypt', (window.location.protocol === "https:"))) { url = 'wss'; } else { url = 'ws'; } //noVNC本質上是用webSocket實時傳輸信息的 url += '://' + host; if(port) { url += ':' + port; } url += '/' + path; rfb = new RFB(document.body, url, { repeaterID: WebUtil.getConfigVar('repeaterID', ''), shared: WebUtil.getConfigVar('shared', true), credentials: { password: password } }); rfb.viewOnly = WebUtil.getConfigVar('view_only', false); rfb.addEventListener("connect", connected); rfb.addEventListener("disconnect", disconnected); rfb.addEventListener("capabilities", function () { updatePowerButtons(); }); rfb.addEventListener("credentialsrequired", credentials); rfb.addEventListener("desktopname", updateDesktopName); rfb.scaleViewport = WebUtil.getConfigVar('scale', false); rfb.resizeSession = WebUtil.getConfigVar('resize', false); })(); </script> </head> <body> <div id="noVNC_status_bar"> <div id="noVNC_left_dummy_elem"></div> <div id="noVNC_status">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> </body> </html>
從代碼里面可以看到,傳輸noVNC虛擬桌面關鍵點在225行的url對應的webSocket鏈接,而這個鏈接恰好就是noVNC提供服務的ip和端口,實際上完全可以把整個頁面內嵌在提供虛擬桌面的窗口,或者寫在同域系統里面,讓其他頁面在iframe框架里面調用。
我們項目用的是Vue.js,為了使頁面能適應Vue系統,把代碼重構成了這樣:

<template> <div id="noVNC_all"> <div id="noVNC_status_bar"> <div id="noVNC_left_dummy_elem"></div> <div id="noVNC_status">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> </template> <script> import * as WebUtil from './webutil.js'; import RFB from '@novnc/novnc/core/rfb.js'; export default { components:{ }, data() { return { rfb:null, desktopName:null }; }, methods: { connectVNC () {}, updateDesktopName(e) { this.desktopName = e.detail.name; }, credentials(e) { var html; var form = document.createElement('form'); form.innerHTML = '<label></label>'; form.innerHTML += '<input type=password size=10 id="password_input">'; form.onsubmit = this.setPassword; // bypass status() because it sets text content document.getElementById('noVNC_status_bar').setAttribute("class", "noVNC_status_warn"); document.getElementById('noVNC_status').innerHTML = ''; document.getElementById('noVNC_status').appendChild(form); document.getElementById('noVNC_status').querySelector('label').textContent = 'Password Required: '; }, setPassword() { this.rfb.sendCredentials({ password: document.getElementById('password_input').value }); return false; }, sendCtrlAltDel() { this.rfb.sendCtrlAltDel(); return false; }, machineShutdown() { this.rfb.machineShutdown(); return false; }, machineReboot() { this.rfb.machineReboot(); return false; }, machineReset() { this.rfb.machineReset(); return false; }, status(text, level) { switch (level) { case 'normal': case 'warn': case 'error': break; default: level = "warn"; } document.getElementById('noVNC_status_bar').className = "noVNC_status_" + level; document.getElementById('noVNC_status').textContent = text; }, connected(e) { document.getElementById('sendCtrlAltDelButton').disabled = false; if (WebUtil.getConfigVar('encrypt', (window.location.protocol === "https:"))) { this.status("Connected (encrypted) to " + this.desktopName, "normal"); } else { this.status("Connected (unencrypted) to " + this.desktopName, "normal"); } }, disconnected(e) { document.getElementById('sendCtrlAltDelButton').disabled = true; this.updatePowerButtons(); if (e.detail.clean) { this.status("Disconnected", "normal"); } else { this.status("Something went wrong, connection is closed", "error"); } }, updatePowerButtons() { var powerbuttons; powerbuttons = document.getElementById('noVNC_power_buttons'); if (this.rfb.capabilities.power) { powerbuttons.className= "noVNC_shown"; } else { powerbuttons.className = "noVNC_hidden"; } } }, mounted() { document.getElementById('sendCtrlAltDelButton').onclick = this.sendCtrlAltDel; document.getElementById('machineShutdownButton').onclick = this.machineShutdown; document.getElementById('machineRebootButton').onclick = this.machineReboot; document.getElementById('machineResetButton').onclick = this.machineReset; WebUtil.init_logging(WebUtil.getConfigVar('logging', 'warn')); document.title = WebUtil.getConfigVar('title', 'noVNC'); // By default, use the host and port of server that served this file var host = WebUtil.getConfigVar('host', window.location.hostname); var port = WebUtil.getConfigVar('port', window.location.port); // if port == 80 (or 443) then it won't be present and should be // set manually if (!port) { if (window.location.protocol.substring(0,5) == 'https') { port = 443; } else if (window.location.protocol.substring(0,4) == 'http') { port = 80; } } if(this.$route.params.ipport.indexOf('-') == -1) var password = WebUtil.getConfigVar('password', '123456');//猜想完全正確,直接就不用驗證了 else var password = WebUtil.getConfigVar('password', ''); var path = WebUtil.getConfigVar('path', 'websockify'); // If a token variable is passed in, set the parameter in a cookie. // This is used by nova-novncproxy. var token = WebUtil.getConfigVar('token', null); if (token) { // if token is already present in the path we should use it path = WebUtil.injectParamIfMissing(path, "token", token); WebUtil.createCookie('token', token, 1) } this.status("Connecting", "normal"); if ((!host) || (!port)) { this.status('Must specify host and port in URL', 'error'); } var url; if (WebUtil.getConfigVar('encrypt', (window.location.protocol === "https:"))) { url = 'wss'; } else { url = 'ws'; } if(this.$route.params.ipport == null) url += '://192.168.80.61:30926/websockify'; else if(this.$route.params.ipport.indexOf('-') == -1) url += '://' + this.$route.params.ipport + '/websockify'; else url += '://localhost:10003/websockify/websockify?token=' + this.$route.params.ipport.split(':-')[1] + '&ip=' + this.$route.params.ipport.split(':-')[0]; this.rfb = new RFB(document.querySelector('#noVNC_all'), url, { repeaterID: WebUtil.getConfigVar('repeaterID', ''), shared: WebUtil.getConfigVar('shared', true), credentials: { password: password } }); this.rfb.viewOnly = WebUtil.getConfigVar('view_only', false); this.rfb.addEventListener("connect", this.connected); this.rfb.addEventListener("disconnect", this.disconnected); this.rfb.addEventListener("capabilities", function () { this.updatePowerButtons(); }); this.rfb.addEventListener("credentialsrequired", this.credentials); this.rfb.addEventListener("desktopname", this.updateDesktopName); this.rfb.scaleViewport = WebUtil.getConfigVar('scale', false); this.rfb.resizeSession = WebUtil.getConfigVar('resize', false); } }; </script> <style lang='scss' scoped> #noVNC_status_bar { width: 100%; display:flex; justify-content: space-between; } #noVNC_status { color: #fff; font: bold 12px Helvetica; margin: auto; } .noVNC_status_normal { background: linear-gradient(#b2bdcd 0%,#899cb3 49%,#7e93af 51%,#6e84a3 100%); } .noVNC_status_error { background: linear-gradient(#c83737 0%,#899cb3 49%,#7e93af 51%,#6e84a3 100%); } .noVNC_status_warn { background: linear-gradient(#b4b41e 0%,#899cb3 49%,#7e93af 51%,#6e84a3 100%); } .noNVC_shown { display: inline; } .noVNC_hidden { display: none; } #noVNC_left_dummy_elem { flex: 1; } #noVNC_buttons { padding: 1px; flex: 1; display: flex; justify-content: flex-end; } </style>
代碼中this.$route.params.ipport可以改成其他提供noVNC服務的ip端口。
WebSocket協議的連接是不會驗證跨域的,所以即使WebSocket的ip端口和本頁面的不同也沒關系。
這個頁面是寫成單獨的vue文件,讓其他vue通過iframe調用的,這個iframe里面的src和外部頁面同域,HTML2canvas成功截到圖
這是成功截圖的GIF: