寫在前言
果子在公眾號( )中看到一個很好的項目。雖然平時用不到,但是對於自己理解SpringBoot,網絡通信還是有好處的。所以就摘錄如下,本文並不是全文照搬,會做出修改潤飾,並加入自己的理解。文末會注明來源,如有侵權,敬請告知。
1、需求目標:
手寫一個可以實現WebSSH連接終端功能的項目
2、技術選型
SpringBoot+Websocket+jsch+xterm.js ,理由如下:
由於webssh需要實時數據交互,所以會選用長連接的WebSocket;
為了開發的方便,框架選用SpringBoot;
另外還自己了解了Java用戶連接ssh的jsch和實現前端shell頁面的xterm.js.
3、依賴導入
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.7.RELEASE</version> <relativePath /> <!-- lookup parent from repository --> </parent> <dependencies> <!-- Web相關 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- jsch支持 --> <dependency> <groupId>com.jcraft</groupId> <artifactId>jsch</artifactId> <version>0.1.54</version> </dependency> <!-- WebSocket 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- 文件上傳解析器 --> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>1.4</version> </dependency> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.3.1</version> </dependency> </dependencies>
4、一個簡單的xterm案例
xterm.js是一個基於WebSocket的容器,它可以幫助我們在前端實現命令行的樣式。就像是我們平常再用SecureCRT或者XShell連接服務器時一樣。
官網入門案例:
<!doctype html> <html> <head> <link rel="stylesheet" href="node_modules/xterm/css/xterm.css" /> <script src="node_modules/xterm/lib/xterm.js"></script> </head> <body> <div id="terminal"></div> <script> var term = new Terminal(); term.open(document.getElementById('terminal')); term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ') </script> </body> </html>
最終測試,頁面就是下面這個樣子:
可以看到頁面已經出現了類似與shell的樣式,那就根據這個繼續深入,實現一個webssh。
5、后端實現
由於xterm只要只是實現了前端的樣式,並不能真正地實現與服務器交互,與服務器交互主要還是靠我們Java后端來進行控制的,所以我們從后端開始,使用jsch+websocket實現這部分內容。
5.1、WebSocket配置
由於消息實時推送到前端需要用到WebSocket,WebSocket的配置如下:
/** * @Description: websocket配置 * @Author: NoCortY * @Date: 2020/3/8 */ @Configuration @EnableWebSocket public class WebSSHWebSocketConfig implements WebSocketConfigurer{ @Autowired WebSSHWebSocketHandler webSSHWebSocketHandler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) { //socket通道 //指定處理器和路徑,並設置跨域 webSocketHandlerRegistry.addHandler(webSSHWebSocketHandler, "/webssh") .addInterceptors(new WebSocketInterceptor()) .setAllowedOrigins("*"); } }
5.2、處理器(Handler)和攔截器(Interceptor)的實現
實現完WebSocket的配置,並指定了一個處理器和攔截器。
接下來對處理器和攔截器進行實現:
攔截器:
1 public class WebSocketInterceptor implements HandshakeInterceptor { 2 /** 3 * @Description: Handler處理前調用 4 * @Param: [serverHttpRequest, serverHttpResponse, webSocketHandler, map] 5 * @return: boolean 6 * @Author: NoCortY 7 * @Date: 2020/3/1 8 */ 9 @Override 10 public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Map<String, Object> map) throws Exception { 11 if (serverHttpRequest instanceof ServletServerHttpRequest) { 12 ServletServerHttpRequest request = (ServletServerHttpRequest) serverHttpRequest; 13 //生成一個UUID,這里由於是獨立的項目,沒有用戶模塊,所以可以用隨機的UUID 14 //但是如果要集成到自己的項目中,需要將其改為自己識別用戶的標識 15 String uuid = UUID.randomUUID().toString().replace("-",""); 16 //將uuid放到websocketsession中 17 map.put(ConstantPool.USER_UUID_KEY, uuid); 18 return true; 19 } else { 20 return false; 21 } 22 } 23 24 @Override 25 public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) { 26 27 } 28 }
處理器:
/** * @Description: WebSSH的WebSocket處理器 * @Author: NoCortY * @Date: 2020/3/8 */ @Component public class WebSSHWebSocketHandler implements WebSocketHandler{ @Autowired private WebSSHService webSSHService; private Logger logger = LoggerFactory.getLogger(WebSSHWebSocketHandler.class); /** * @Description: 用戶連接上WebSocket的回調 * @Param: [webSocketSession] * @return: void * @Author: Object * @Date: 2020/3/8 */ @Override public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception { logger.info("用戶:{},連接WebSSH", webSocketSession.getAttributes().get(ConstantPool.USER_UUID_KEY)); //調用初始化連接 webSSHService.initConnection(webSocketSession); } /** * @Description: 收到消息的回調 * @Param: [webSocketSession, webSocketMessage] * @return: void * @Author: NoCortY * @Date: 2020/3/8 */ @Override public void handleMessage(WebSocketSession webSocketSession, WebSocketMessage<?> webSocketMessage) throws Exception { if (webSocketMessage instanceof TextMessage) { logger.info("用戶:{},發送命令:{}", webSocketSession.getAttributes().get(ConstantPool.USER_UUID_KEY), webSocketMessage.toString()); //調用service接收消息 webSSHService.recvHandle(((TextMessage) webSocketMessage).getPayload(), webSocketSession); } else if (webSocketMessage instanceof BinaryMessage) { } else if (webSocketMessage instanceof PongMessage) { } else { System.out.println("Unexpected WebSocket message type: " + webSocketMessage); } } /** * @Description: 出現錯誤的回調 * @Param: [webSocketSession, throwable] * @return: void * @Author: Object * @Date: 2020/3/8 */ @Override public void handleTransportError(WebSocketSession webSocketSession, Throwable throwable) throws Exception { logger.error("數據傳輸錯誤"); } /** * @Description: 連接關閉的回調 * @Param: [webSocketSession, closeStatus] * @return: void * @Author: NoCortY * @Date: 2020/3/8 */ @Override public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus closeStatus) throws Exception { logger.info("用戶:{}斷開webssh連接", String.valueOf(webSocketSession.getAttributes().get(ConstantPool.USER_UUID_KEY))); //調用service關閉連接 webSSHService.close(webSocketSession); } @Override public boolean supportsPartialMessages() { return false; } }
需要注意的是,這里在攔截器中加入的用戶標識是使用了隨機的UUID ,這是因為作為一個獨立的websocket項目,沒有用戶模塊,如果需要將這個項目集成到自己的項目中,需要修改這部分代碼,將其改為自己項目中識別一個用戶所用的用戶標識。
5.3、WebSSH的業務邏輯實現(核心)
上面我們實現了websocket的配置,都是一些死代碼,實現了接口再根據自身需求即可實現,現在我們將進行后端主要業務邏輯的實現,在實現這個邏輯之前,我們先來想想,WebSSH,我們主要想要呈現一個什么效果。
總結如下:
1.首先我們得先連接上終端(初始化連接) 2.其次我們的服務端需要處理來自前端的消息(接收並處理前端消息) 3.我們需要將終端返回的消息回寫到前端(數據回寫前端) 4.關閉連接
根據這四個需求,我們先定義一個接口,這樣可以讓需求明了起來。
/** * @Description: WebSSH的業務邏輯 * @Author: NoCortY * @Date: 2020/3/7 */ public interface WebSSHService { /** * @Description: 初始化ssh連接 * @Param: * @return: * @Author: NoCortY * @Date: 2020/3/7 */ public void initConnection(WebSocketSession session); /** * @Description: 處理客戶段發的數據 * @Param: * @return: * @Author: NoCortY * @Date: 2020/3/7 */ public void recvHandle(String buffer, WebSocketSession session); /** * @Description: 數據寫回前端 for websocket * @Param: * @return: * @Author: NoCortY * @Date: 2020/3/7 */ public void sendMessage(WebSocketSession session, byte[] buffer) throws IOException; /** * @Description: 關閉連接 * @Param: * @return: * @Author: NoCortY * @Date: 2020/3/7 */ public void close(WebSocketSession session); }
現在我們可以根據這個接口去實現我們定義的功能了。
至此,我們的整個后端實現就結束了。
這里將一些操作封裝成了方法,重點在於邏輯實現的思路。
接下來我們將進行前端的實現。
5.3.1、初始化連接
由於我們的底層是依賴jsch實現的,所以這里是需要使用jsch去建立連接的。而所謂初始化連接,實際上就是將我們所需要的連接信息,保存在一個Map中,這里並不進行任何的真實連接操作。為什么這里不直接進行連接?因為這里前端只是連接上了WebSocket,但是我們還需要前端給我們發來linux終端的用戶名和密碼,沒有這些信息,我們是無法進行連接的。
public void initConnection(WebSocketSession session) { JSch jSch = new JSch(); SSHConnectInfo sshConnectInfo = new SSHConnectInfo(); sshConnectInfo.setjSch(jSch); sshConnectInfo.setWebSocketSession(session); String uuid = String.valueOf(session.getAttributes().get(ConstantPool.USER_UUID_KEY)); //將這個ssh連接信息放入map中 sshMap.put(uuid, sshConnectInfo); }
5.3.2、處理客戶端發送的數據
在這一步驟中,我們會分為兩個分支。
第一個分支:如果客戶端發來的是終端的用戶名和密碼等信息,那么我們進行終端的連接。
第二個分支:如果客戶端發來的是操作終端的命令,那么我們就直接轉發到終端並且獲取終端的執行結果。
具體代碼實現:
1 public void recvHandle(String buffer, WebSocketSession session) { 2 ObjectMapper objectMapper = new ObjectMapper(); 3 WebSSHData webSSHData = null; 4 try { 5 //轉換前端發送的JSON 6 webSSHData = objectMapper.readValue(buffer, WebSSHData.class); 7 } catch (IOException e) { 8 logger.error("Json轉換異常"); 9 logger.error("異常信息:{}", e.getMessage()); 10 return; 11 } 12 //獲取剛才設置的隨機的uuid 13 String userId = String.valueOf(session.getAttributes().get(ConstantPool.USER_UUID_KEY)); 14 if (ConstantPool.WEBSSH_OPERATE_CONNECT.equals(webSSHData.getOperate())) { 15 //如果是連接請求 16 //找到剛才存儲的ssh連接對象 17 SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(userId); 18 //啟動線程異步處理 19 WebSSHData finalWebSSHData = webSSHData; 20 executorService.execute(new Runnable() { 21 @Override 22 public void run() { 23 try { 24 //連接到終端 25 connectToSSH(sshConnectInfo, finalWebSSHData, session); 26 } catch (JSchException | IOException e) { 27 logger.error("webssh連接異常"); 28 logger.error("異常信息:{}", e.getMessage()); 29 close(session); 30 } 31 } 32 }); 33 } else if (ConstantPool.WEBSSH_OPERATE_COMMAND.equals(webSSHData.getOperate())) { 34 //如果是發送命令的請求 35 String command = webSSHData.getCommand(); 36 SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(userId); 37 if (sshConnectInfo != null) { 38 try { 39 //發送命令到終端 40 transToSSH(sshConnectInfo.getChannel(), command); 41 } catch (IOException e) { 42 logger.error("webssh連接異常"); 43 logger.error("異常信息:{}", e.getMessage()); 44 close(session); 45 } 46 } 47 } else { 48 logger.error("不支持的操作"); 49 close(session); 50 } 51 }
5.3.3、數據通過websocket發送到前端
public void sendMessage(WebSocketSession session, byte[] buffer) throws IOException { session.sendMessage(new TextMessage(buffer)); }
5.3.4、關閉連接
public void close(WebSocketSession session) { //獲取隨機生成的uuid String userId = String.valueOf(session.getAttributes().get(ConstantPool.USER_UUID_KEY)); SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(userId); if (sshConnectInfo != null) { //斷開連接 if (sshConnectInfo.getChannel() != null) sshConnectInfo.getChannel().disconnect(); //map中移除該ssh連接信息 sshMap.remove(userId); } }
6、前端實現
前端工作主要分以下三步:
- 頁面的實現;
- 連接WebSocket並完成數據的接收並回寫;
- 數據的發送;
6.1、頁面實現
前端頁面只需要在一整個屏幕上都顯示終端那種大黑屏幕,所以我們並不用寫什么樣式,只需要創建一個div,之后將terminal實例通過xterm放到這個div中,就可以實現了。
<!doctype html> <html> <head> <title>WebSSH</title> <link rel="stylesheet" href="../css/xterm.css" /> </head> <body> <div id="terminal" style="width: 100%;height: 100%"></div> <script src="../lib/jquery-3.4.1/jquery-3.4.1.min.js"></script> <script src="../js/xterm.js" charset="utf-8"></script> <script src="../js/webssh.js" charset="utf-8"></script> <script src="../js/base64.js" charset="utf-8"></script> </body> </html>
6.2、連接WebSocket並完成數據的發送、接收、回寫
1 openTerminal( { 2 //這里的內容可以寫死,但是要整合到項目中時,需要通過參數的方式傳入,可以動態連接某個終端。 3 operate:'connect', 4 host: 'ip地址', 5 port: '端口號', 6 username: '用戶名', 7 password: '密碼' 8 }); 9 function openTerminal(options){ 10 var client = new WSSHClient(); 11 var term = new Terminal({ 12 cols: 97, 13 rows: 37, 14 cursorBlink: true, // 光標閃爍 15 cursorStyle: "block", // 光標樣式 null | 'block' | 'underline' | 'bar' 16 scrollback: 800, //回滾 17 tabStopWidth: 8, //制表寬度 18 screenKeys: true 19 }); 20 21 term.on('data', function (data) { 22 //鍵盤輸入時的回調函數 23 client.sendClientData(data); 24 }); 25 term.open(document.getElementById('terminal')); 26 //在頁面上顯示連接中... 27 term.write('Connecting...'); 28 //執行連接操作 29 client.connect({ 30 onError: function (error) { 31 //連接失敗回調 32 term.write('Error: ' + error + '\r\n'); 33 }, 34 onConnect: function () { 35 //連接成功回調 36 client.sendInitData(options); 37 }, 38 onClose: function () { 39 //連接關閉回調 40 term.write("\rconnection closed"); 41 }, 42 onData: function (data) { 43 //收到數據時回調 44 term.write(data); 45 } 46 }); 47 }
7、效果展示
7.1、連接
7.2、連接成功
7.3、命令操作
ls命令:
vim編輯器:
top命令:
8、總結
至此,我們就完成了一個webssh項目的實現,沒有依賴其它任何的組件,后端完全使用Java實現,由於用了SpringBoot,非常容易部署。
該項目也還有缺點,還可以對其進行進一步擴展,比如新增上傳或下載文件,就像Xftp一樣,可以很方便地拖拽式上傳下載文件。
參考及致謝:
1、項目開源連接:https://github.com/NoCortY/WebSSH
.......