如何解決Vue.js里面noVNC的截圖問題(1)——論可以跨域的webSocket


  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的代碼

  從代碼里面可以看到,傳輸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>
Vue版noVNC虛擬桌面

  代碼中this.$route.params.ipport可以改成其他提供noVNC服務的ip端口。

  WebSocket協議的連接是不會驗證跨域的,所以即使WebSocket的ip端口和本頁面的不同也沒關系。

  這個頁面是寫成單獨的vue文件,讓其他vue通過iframe調用的,這個iframe里面的src和外部頁面同域,HTML2canvas成功截到圖

  這是成功截圖的GIF:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM