• 背景

​ 使用者需要通過前端HTML頁面的noVNC(noVNC是什麼?)客戶端連線底層VNC Server服務端,為了防止VNC Server的IP暴露,因此需要做一層代理。正常情況下使用Nginx、Apache等都可以搞定,但是由於專案架構的一些問題,暫時不能再加一臺反向代理伺服器,所以決定寫一個單獨的模組實現反向代理的功能。

​ 在網上和Github上找了一下,使用了HTTP-Proxy-Servlet,引入該依賴搭建一個Spring Boot專案。

  • 搭建

  1. 引入代理的依賴

    <dependency>
    <groupId>org.mitre.dsmiley.httpproxy</groupId>
    <artifactId>smiley-http-proxy-servlet</artifactId>
    <version>1.12</version>
    </dependency>
  2. 通過註冊bean攔截指定URL路徑進行自定義操作

    @Configuration
    public class ProxyServletConfiguration { // 攔截所有請求交給下面的VNCProxyServlet去處理
    private final static String SERVLET_URL = "/*"; @Bean
    public ServletRegistrationBean<VNCProxyServlet> servletServletRegistrationBean() {
    ServletRegistrationBean<VNCProxyServlet> servletRegistrationBean = new ServletRegistrationBean<>(new VNCProxyServlet(), SERVLET_URL);
    //設定網址以及引數
    Map<String, String> params = ImmutableMap.of(
    "targetUri", "null", //這裡寫null是因為targetUri是在自定義的VNCProxyServlet類中動態傳入的,而且這裡必須要有值
    ProxyServlet.P_LOG, "true",
    ProxyServlet.P_PRESERVEHOST,"true",
    ProxyServlet.P_PRESERVECOOKIES,"true"
    );
    servletRegistrationBean.setInitParameters(params);
    return servletRegistrationBean;
    } }

    這裡遇到的坑:

    ​ 剛開始其實是準備在已有的一個模組中加上這個代理功能,因為可以指定攔截的路徑,比如只攔截請求路徑為/proxy/*的,然後交給自定義的Servlet去代理,後來寫好測試時,發現代理過去後代理目標主頁一片空白,看了控制檯的Network後,主頁確實是返回200且載入正常,但是由主頁發起的js、css和img等靜態資源狀態碼都為404。

    ​ 當時以為是程式碼的問題,後來發現靜態資源都是相對路徑的有問題,如果前端的靜態資源是引入第三方的,比如從CDN中引入Vue.js則不會出現問題,都可以正常的被代理。既然狀態碼是404,那肯定是找不到這個資源,看了一下發現如果在靜態資源的路徑前加上指定攔截的路徑/proxy/就可以被正常代理。此時才明白,因為訪問首頁的路徑中帶/proxy/是在位址列主動輸入的,所以請求到後臺,後臺Servlet攔截髮現路徑中帶/proxy/,把該請求交給自定義的代理Servlet去處理然後返回。而主頁上的js、css等靜態資源發起請求的路徑是不會帶/proxy/**的,因此不會走到代理Servlet,並且代理模組中也沒有相應資源路徑,所以就理所應當的返回了404。

    ​ 為此還專門在GitHub上問了一下作者,作者也是回覆說這並不是這個代理模組該做的事,最好是前端處理,或者讓前端使用絕對路徑。附上地址(Discussions

    ​ 最後就是決定單獨拉出來寫一個Spring Boot專案做這個代理功能模組,直接代理/*,這樣所有請求到這個模組的都會被代理。

  3. 自定義Servlet實現動態代理目標地址

    // VNCProxyServlet繼承了ProxyServlet 重寫了service方法 在方法中新增自定義操作 從請求地址中動態獲取
    @Override
    protected void service(HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws ServletException, IOException {
    // 獲取請求地址
    String targetUri = servletRequest.getRequestURL().toString(); // 正則取請求地址中的引數 引數是放在域名中的
    Matcher matcher = DOMAIN_REX.matcher(targetUri);
    if(!matcher.find()){
    // 自定義的異常
    throw new GenericException("從域名中獲取vmId異常!");
    }
    // 取域名中的第一個 eg: http://vmId.xxx.cn得 [vmId,xxx,cn] 得 vmId
    Long vmId = Long.valueOf(matcher.group().split("\\.")[0]); // eg:業務邏輯根據vmId去拿 targetUri
    targetUri = vmService.getTargetUrl(vmId); if (StringUtils.isEmpty(targetUri)) {
    throw new GenericException("代理路徑不正確,請確認路徑");
    } // 設定Url
    if (servletRequest.getAttribute(ATTR_TARGET_URI) == null) {
    servletRequest.setAttribute(ATTR_TARGET_URI, targetUri);
    } // 設定Host
    if (servletRequest.getAttribute(ATTR_TARGET_HOST) == null) {
    URL trueUrl = URLUtil.url(targetUri);
    servletRequest.setAttribute(ATTR_TARGET_HOST, new HttpHost(trueUrl.getHost(), trueUrl.getPort(), trueUrl.getProtocol()));
    }
    // 下面大部分都是父類的原始碼 沒有需要特別修改的地方
    String method = servletRequest.getMethod();
    // 替換多餘路徑
    String proxyRequestUri = this.rewriteUrlFromRequest(servletRequest); HttpRequest proxyRequest;
    if (servletRequest.getHeader(HttpHeaders.CONTENT_LENGTH) != null ||
    servletRequest.getHeader(HttpHeaders.TRANSFER_ENCODING) != null) {
    proxyRequest = new BasicHttpRequest(method, proxyRequestUri);
    } else {
    proxyRequest = this.newProxyRequestWithEntity(method, proxyRequestUri, servletRequest);
    } this.copyRequestHeaders(servletRequest, proxyRequest);
    setXForwardedForHeader(servletRequest, proxyRequest);
    HttpResponse proxyResponse = null; try {
    // Execute the request
    proxyResponse = this.doExecute(servletRequest, servletResponse, proxyRequest); // Process the response
    int statusCode = proxyResponse.getStatusLine().getStatusCode(); // "reason phrase" is deprecated but it's the only way to pass
    servletResponse.setStatus(statusCode, proxyResponse.getStatusLine().getReasonPhrase()); // copying response headers to make sure SESSIONID or other Cookie which comes from remote server
    // will be saved in client when the proxied url was redirected to another one.
    copyResponseHeaders(proxyResponse, servletRequest, servletResponse); if (statusCode == HttpServletResponse.SC_NOT_MODIFIED) {
    servletResponse.setIntHeader(HttpHeaders.CONTENT_LENGTH, 0);
    } else {
    copyResponseEntity(proxyResponse, servletResponse, proxyRequest, servletRequest);
    }
    } catch (Exception e) {
    handleRequestException(proxyRequest, proxyResponse, e);
    } finally {
    if (proxyResponse != null) {
    EntityUtils.consumeQuietly(proxyResponse.getEntity());
    }
    }
    }

    ​ 這裡主要列出關鍵部分,詳細程式碼可以參考GitHub上的程式碼。

  • 問題

​ 本以為這樣就成功了,但是測試之後發現頁面和靜態資源都代理過去了,但是有一個websocket請求失敗了。像noVNC這種網頁版的黑視窗,早就該想到肯定是用websocket這種長連結的請求進行互動的。後來去搜了一下這個叫websockify的請求,就是最開始介紹noVNC部落格中介紹的:

​ 瀏覽器不支援VNC,所以不能直接連線VNC,但是可以使用代理,使用noVNC通過WebSocket建立連線,而VNC Server不支援WebSocket,所以需要開啟Websockify代理來做WebSocket和TCP Socket之間的轉換,這個代理在noVNC的目錄裡,叫做websockify。

​ 此時專案是能夠攔截到websockify這個請求的,但是由於servlet把這個請求當成普通的請求去代理到目標伺服器,這樣是無法成功的,所以要做的就是類似實現一個websocket的反向代理,搜了一下的話發現例子不是很多,大多都是在前端做的,前端作為客戶端與服務端建立websocket連線,但目前的狀況很明顯是需要這個代理模組既做websocket服務端與web端建立連線,再作為websocket客戶端與VNC 服務端建立連線,然後進行互動傳遞通訊。

​ 後面也找到了這篇部落格通過noVNC和websockify連線到QEMU/KVM,然後總結一下從使用者發出請求到得到響應的流程:

PC Chrome(客戶端) => noVNC Server(noVNC端) => websockify(websocket轉TCP Socket) => VNC Server(VNC服務端) => websockify(TCP Socket轉websocket) => noVNC Server(noVNC端)=> PC Chrome(客戶端)

使用者使用PC Chrome瀏覽器請求 noVNC端(因為無法直接訪問VNC Server端,VNC Server是不支援Websocket連線),經由websockify將websocket轉為TCP Socket請求到VNC服務端,返回TCP響應,經由websockify轉換為websocket返回給客戶端瀏覽器,這樣來進行互動。整個過程 websockify 代理器是關鍵,noVNC 可以被放在瀏覽器端。

  1. noVNC網頁端與代理模組建立websocket通訊

    @Configuration
    public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
    return new ServerEndpointExporter();
    }
    }
    @ServerEndpoint("/websockify")
    @Component
    public class WebSocketServer { /**
    * 連線建立成功呼叫的方法
    */
    @OnOpen
    public void onOpen(Session session) {
    logger.info("open...");
    } /**
    * 連線關閉呼叫的方法
    */
    @OnClose
    public void onClose() {
    logger.info("close...");
    } /**
    * 收到客戶端訊息後呼叫的方法
    */
    @OnMessage
    public void onMessage(String message, Session session) {
    logger.info(message);
    } /**
    * 發生錯誤呼叫的方法
    */
    @OnError
    public void onError(Session session, Throwable error) {
    logger.error("使用者錯誤原因:"+error.getMessage());
    error.printStackTrace();
    } }

    ​ 都是很常用的websocket服務端的程式碼,唯一要注意的是前端請求'/websockify'地址發起websocket連線時,要注意用ip,尤其是本地,使用localhost會報錯,要使用127.0.0.1。最後測試連線成功,返回狀態碼101,並且訊息可以正常接收。noVNC網頁端與代理模組建立websocket通訊完成。

  2. 代理模組與VNC Server建立websocket通訊

    java後臺作為websocket客戶端很少,大多是用Netty去寫的,但是不適合目前的情況,最後還是找到了一個感覺比較合適的

    public class MyWebSocketClient {
    public static WebSocketClient mWs;
    public static void main(String[] args) {
    try {
    //
    String url = "ws://172.28.132.11:8888/websocketify";
    URI uri = new URI(url);
    HashMap<String, String> httpHeadersMap = new HashMap<>();
    httpHeadersMap.put("Sec-WebSocket-Version", "13");
    httpHeadersMap.put("Sec-WebSocket-Key", "YBhzbbwLI83U5EH8Tlutwg==");
    httpHeadersMap.put("Connection","Upgrade");
    httpHeadersMap.put("Upgrade","websocket");
    httpHeadersMap.put("User-Agent","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36");
    httpHeadersMap.put("Cookie","token=8asda2das-84easdac-asdaqwe4-2asda-asdsadas");
    httpHeadersMap.put("Sec-WebSocket-Extensions","permessage-deflate; client_max_window_bits");
    mWs = new WebSocketClient(uri,httpHeadersMap){
    @Override
    public void onOpen(ServerHandshake serverHandshake) {
    System.out.println("open...");
    System.out.println(serverHandshake.getHttpStatus());
    mWs.send("666");
    } @Override
    public void onMessage(String s) {
    System.out.println(s);
    } @Override
    public void onClose(int i, String s, boolean b) {
    System.out.println("close...");
    System.out.println(i);
    System.out.println(s);
    System.out.println(b);
    } @Override
    public void onError(Exception e) {
    System.out.println("發生了錯誤...");
    }
    };
    mWs.connect();
    } catch (Exception e) {
    e.printStackTrace();
    } }
    } // 呼叫後報錯 直接關閉了連線 狀態碼為1002
    // close...
    // 1002
    // Invalid status code received: 400 Status line: HTTP/1.1 400 Client must support 'binary' or 'base64' protocol

    ​ 發生錯誤後,發現關鍵地方,客戶端必須支援 binary或base64協議,一番搜尋後再Stack Overflow找到了線索,並且是Kanaka(noVNC和websockify的開發者)親自回答的,大概意思就是擬需要在建構函式中提供這些協議。

    ​ 然後我又在websockify.js的原始碼中找到了這個構造,確實需要傳遞一個protocols的陣列引數,可是這是前端,並不知道Java如何完成這個操作。

  • 後續

​ 首先再次感謝開源專案和各位博主大佬的分享,依舊在尋找解決方案......