Springboot實現(xiàn)VNC的反向代理功能
背景
用戶需要通過前端HTML頁面的noVNC(noVNC是什么?)客戶端連接底層VNC Server服務(wù)端,為了防止VNC Server的IP暴露,因此需要做一層代理。正常情況下使用Nginx、Apache等都可以搞定,但是由于項目架構(gòu)的一些問題,暫時不能再加一臺反向代理服務(wù)器,所以決定寫一個單獨的模塊實現(xiàn)反向代理的功能。
在網(wǎng)上和Github上找了一下,使用了HTTP-Proxy-Servlet,引入該依賴搭建一個Spring Boot項目。
搭建
引入代理的依賴
<dependency>
<groupId>org.mitre.dsmiley.httpproxy</groupId>
<artifactId>smiley-http-proxy-servlet</artifactId>
<version>1.12</version>
</dependency>
通過注冊bean攔截指定URL路徑進(jìn)行自定義操作
@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);
//設(shè)置網(wǎng)址以及參數(shù)
Map<String, String> params = ImmutableMap.of(
"targetUri", "null", //這里寫null是因為targetUri是在自定義的VNCProxyServlet類中動態(tài)傳入的,而且這里必須要有值
ProxyServlet.P_LOG, "true",
ProxyServlet.P_PRESERVEHOST,"true",
ProxyServlet.P_PRESERVECOOKIES,"true"
);
servletRegistrationBean.setInitParameters(params);
return servletRegistrationBean;
}
}
這里遇到的坑:
剛開始其實是準(zhǔn)備在已有的一個模塊中加上這個代理功能,因為可以指定攔截的路徑,比如只攔截請求路徑為/proxy/*的,然后交給自定義的Servlet去代理,后來寫好測試時,發(fā)現(xiàn)代理過去后代理目標(biāo)主頁一片空白,看了控制臺的Network后,主頁確實是返回200且加載正常,但是由主頁發(fā)起的js、css和img等靜態(tài)資源狀態(tài)碼都為404。


當(dāng)時以為是代碼的問題,后來發(fā)現(xiàn)靜態(tài)資源都是相對路徑的有問題,如果前端的靜態(tài)資源是引入第三方的,比如從CDN中引入Vue.js則不會出現(xiàn)問題,都可以正常的被代理。既然狀態(tài)碼是404,那肯定是找不到這個資源,看了一下發(fā)現(xiàn)如果在靜態(tài)資源的路徑前加上指定攔截的路徑/proxy/就可以被正常代理。此時才明白,因為訪問首頁的路徑中帶/proxy/是在地址欄主動輸入的,所以請求到后臺,后臺Servlet攔截發(fā)現(xiàn)路徑中帶/proxy/,把該請求交給自定義的代理Servlet去處理然后返回。而主頁上的js、css等靜態(tài)資源發(fā)起請求的路徑是不會帶/proxy/*的,因此不會走到代理Servlet,并且代理模塊中也沒有相應(yīng)資源路徑,所以就理所應(yīng)當(dāng)?shù)姆祷亓?04。
為此還專門在GitHub上問了一下作者,作者也是回復(fù)說這并不是這個代理模塊該做的事,最好是前端處理,或者讓前端使用絕對路徑。附上地址(Discussions)

最后就是決定單獨拉出來寫一個Spring Boot項目做這個代理功能模塊,直接代理/*,這樣所有請求到這個模塊的都會被代理。
自定義Servlet實現(xiàn)動態(tài)代理目標(biāo)地址
// VNCProxyServlet繼承了ProxyServlet 重寫了service方法 在方法中添加自定義操作 從請求地址中動態(tài)獲取
@Override
protected void service(HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws ServletException, IOException {
// 獲取請求地址
String targetUri = servletRequest.getRequestURL().toString();
// 正則取請求地址中的參數(shù) 參數(shù)是放在域名中的
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:業(yè)務(wù)邏輯根據(jù)vmId去拿 targetUri
targetUri = vmService.getTargetUrl(vmId);
if (StringUtils.isEmpty(targetUri)) {
throw new GenericException("代理路徑不正確,請確認(rèn)路徑");
}
// 設(shè)置Url
if (servletRequest.getAttribute(ATTR_TARGET_URI) == null) {
servletRequest.setAttribute(ATTR_TARGET_URI, targetUri);
}
// 設(shè)置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());
}
}
}
這里主要列出關(guān)鍵部分,因為方案還沒有完結(jié)。
問題
本以為這樣就成功了,但是測試之后發(fā)現(xiàn)頁面和靜態(tài)資源都代理過去了,但是有一個websocket請求失敗了。像noVNC這種網(wǎng)頁版的黑窗口,早就該想到肯定是用websocket這種長鏈接的請求進(jìn)行交互的。后來去搜了一下這個叫websockify的請求,就是最開始介紹noVNC博客中介紹的:
瀏覽器不支持VNC,所以不能直接連接VNC,但是可以使用代理,使用noVNC通過WebSocket建立連接,而VNC Server不支持WebSocket,所以需要開啟Websockify代理來做WebSocket和TCP Socket之間的轉(zhuǎn)換,這個代理在noVNC的目錄里,叫做websockify。
此時項目是能夠攔截到websockify這個請求的,但是由于servlet把這個請求當(dāng)成普通的請求去代理到目標(biāo)服務(wù)器,這樣是無法成功的,所以要做的就是類似實現(xiàn)一個websocket的反向代理,搜了一下的話發(fā)現(xiàn)例子不是很多,大多都是在前端做的,前端作為客戶端與服務(wù)端建立websocket連接,但目前的狀況很明顯是需要這個代理模塊既做websocket服務(wù)端與web端建立連接,再作為websocket客戶端與VNC 服務(wù)端建立連接,然后進(jìn)行交互傳遞通信。
后面也找到了這篇通過noVNC和websockify連接到QEMU/KVM,然后總結(jié)一下從用戶發(fā)出請求到得到響應(yīng)的流程:
PC Chrome(客戶端) => noVNC Server(noVNC端) => websockify(websocket轉(zhuǎn)TCP Socket) => VNC Server(VNC服務(wù)端) => websockify(TCP Socket轉(zhuǎn)websocket) => noVNC Server(noVNC端)=> PC Chrome(客戶端)
用戶使用PC Chrome瀏覽器請求 noVNC端(因為無法直接訪問VNC Server端,VNC Server是不支持Websocket連接),經(jīng)由websockify將websocket轉(zhuǎn)為TCP Socket請求到VNC服務(wù)端,返回TCP響應(yīng),經(jīng)由websockify轉(zhuǎn)換為websocket返回給客戶端瀏覽器,這樣來進(jìn)行交互。整個過程 websockify 代理器是關(guān)鍵,noVNC 可以被放在瀏覽器端。
1.noVNC網(wǎng)頁端與代理模塊建立websocket通信
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
@ServerEndpoint("/websockify")
@Component
public class WebSocketServer {
/**
* 連接建立成功調(diào)用的方法
*/
@OnOpen
public void onOpen(Session session) {
logger.info("open...");
}
/**
* 連接關(guān)閉調(diào)用的方法
*/
@OnClose
public void onClose() {
logger.info("close...");
}
/**
* 收到客戶端消息后調(diào)用的方法
*/
@OnMessage
public void onMessage(String message, Session session) {
logger.info(message);
}
/**
* 發(fā)生錯誤調(diào)用的方法
*/
@OnError
public void onError(Session session, Throwable error) {
logger.error("用戶錯誤原因:"+error.getMessage());
error.printStackTrace();
}
}
都是很常用的websocket服務(wù)端的代碼,唯一要注意的是前端請求'/websockify'地址發(fā)起websocket連接時,要注意用ip,尤其是本地,使用localhost會報錯,要使用127.0.0.1。最后測試連接成功,返回狀態(tài)碼101,并且消息可以正常接收。noVNC網(wǎng)頁端與代理模塊建立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("發(fā)生了錯誤...");
}
};
mWs.connect();
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 調(diào)用后報錯 直接關(guān)閉了連接 狀態(tài)碼為1002
// close...
// 1002
// Invalid status code received: 400 Status line: HTTP/1.1 400 Client must support 'binary' or 'base64' protocol
發(fā)生錯誤后,發(fā)現(xiàn)關(guān)鍵地方,客戶端必須支持 binary或base64協(xié)議,一番搜索后再Stack Overflow找到了線索,并且是Kanaka(noVNC和websockify的開發(fā)者)親自回答的,大概意思就是你需要在構(gòu)造函數(shù)中提供這些協(xié)議。

然后我又在websockify.js的源碼中找到了這個構(gòu)造,確實需要傳遞一個protocols的數(shù)組參數(shù),可是這是前端,并不知道Java如何完成這個操作。

后續(xù)
首先再次感謝開源項目和各位博主大佬的分享,依舊在尋找解決方案......
到此這篇關(guān)于Springboot實現(xiàn)VNC的反向代理功能的文章就介紹到這了,更多相關(guān)Springboot反向代理內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring(AbstractRoutingDataSource)實現(xiàn)動態(tài)數(shù)據(jù)源切換示例
本篇文章主要介紹了詳解Spring(AbstractRoutingDataSource)實現(xiàn)動態(tài)數(shù)據(jù)源切換,具有一定的參考價值,感興趣的小伙伴們可以參考一下。2017-02-02
通過Docker啟動Solace并在Spring?Boot通過JMS整合Solace的操作方法
本文將介紹如何在Spring中使用,雖然代碼使用的是Spring Boot,但并沒有使用相關(guān)starter,跟Spring的整合一樣,可通用,JMS是通過的消息處理框架,可以深入學(xué)習(xí)一下,不同的MQ在JMS的整合上都是類似的,感興趣的朋友跟隨小編一起看看吧2023-01-01
spring NamedContextFactory實現(xiàn)服務(wù)隔離的示例詳解
假設(shè)我們有個場景,我們需要實現(xiàn)服務(wù)之間的數(shù)據(jù)隔離、配置隔離、依賴的spring bean之間隔離,大家會有什么實現(xiàn)思路?今天給大家介紹spring-cloud-context里面有個NamedContextFactory可以達(dá)到上面的效果,需要的朋友可以參考下2024-05-05
Feign調(diào)用服務(wù)時丟失Cookie和Header信息的解決方案
這篇文章主要介紹了Feign調(diào)用服務(wù)時丟失Cookie和Header信息的解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-03-03

