From 97ed24f5922159daa24fcafa88b6be9770eddbf9 Mon Sep 17 00:00:00 2001 From: nagocoler Date: Thu, 14 Dec 2023 18:18:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90SSH=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itbaima-monitor-server/pom.xml | 9 + .../example/config/SecurityConfiguration.java | 1 + .../config/WebSocketConfiguration.java | 13 ++ .../example/websocket/TerminalWebSocket.java | 155 ++++++++++++++++++ itbaima-monitor-web/package-lock.json | 17 +- itbaima-monitor-web/package.json | 4 +- .../src/component/Terminal.vue | 59 +++++++ .../src/component/TerminalWindow.vue | 14 +- itbaima-monitor-web/src/views/main/Manage.vue | 7 +- 9 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 itbaima-monitor-server/src/main/java/com/example/config/WebSocketConfiguration.java create mode 100644 itbaima-monitor-server/src/main/java/com/example/websocket/TerminalWebSocket.java create mode 100644 itbaima-monitor-web/src/component/Terminal.vue diff --git a/itbaima-monitor-server/pom.xml b/itbaima-monitor-server/pom.xml index 8538b60..c550322 100644 --- a/itbaima-monitor-server/pom.xml +++ b/itbaima-monitor-server/pom.xml @@ -100,6 +100,15 @@ influxdb-client-java 6.6.0 + + org.springframework.boot + spring-boot-starter-websocket + + + com.jcraft + jsch + 0.1.55 + diff --git a/itbaima-monitor-server/src/main/java/com/example/config/SecurityConfiguration.java b/itbaima-monitor-server/src/main/java/com/example/config/SecurityConfiguration.java index 9ede5fc..d876761 100644 --- a/itbaima-monitor-server/src/main/java/com/example/config/SecurityConfiguration.java +++ b/itbaima-monitor-server/src/main/java/com/example/config/SecurityConfiguration.java @@ -53,6 +53,7 @@ public class SecurityConfiguration { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(conf -> conf + .requestMatchers("/terminal/**").permitAll() .requestMatchers("/api/auth/**", "/error").permitAll() .requestMatchers("/monitor/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() diff --git a/itbaima-monitor-server/src/main/java/com/example/config/WebSocketConfiguration.java b/itbaima-monitor-server/src/main/java/com/example/config/WebSocketConfiguration.java new file mode 100644 index 0000000..bae9372 --- /dev/null +++ b/itbaima-monitor-server/src/main/java/com/example/config/WebSocketConfiguration.java @@ -0,0 +1,13 @@ +package com.example.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.server.standard.ServerEndpointExporter; + +@Configuration +public class WebSocketConfiguration { + @Bean + public ServerEndpointExporter serverEndpointExporter() { + return new ServerEndpointExporter(); + } +} diff --git a/itbaima-monitor-server/src/main/java/com/example/websocket/TerminalWebSocket.java b/itbaima-monitor-server/src/main/java/com/example/websocket/TerminalWebSocket.java new file mode 100644 index 0000000..4810153 --- /dev/null +++ b/itbaima-monitor-server/src/main/java/com/example/websocket/TerminalWebSocket.java @@ -0,0 +1,155 @@ +package com.example.websocket; + +import com.example.entity.dto.ClientDetail; +import com.example.entity.dto.ClientSsh; +import com.example.mapper.ClientDetailMapper; +import com.example.mapper.ClientSshMapper; +import com.jcraft.jsch.ChannelShell; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import jakarta.annotation.Resource; +import jakarta.websocket.*; +import jakarta.websocket.server.PathParam; +import jakarta.websocket.server.ServerEndpoint; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@Component +@ServerEndpoint("/terminal/{clientId}") +public class TerminalWebSocket { + + private static ClientDetailMapper detailMapper; + + @Resource + public void setDetailMapper(ClientDetailMapper detailMapper) { + TerminalWebSocket.detailMapper = detailMapper; + } + + private static ClientSshMapper sshMapper; + + @Resource + public void setSshMapper(ClientSshMapper sshMapper) { + TerminalWebSocket.sshMapper = sshMapper; + } + + private static final Map sessionMap = new ConcurrentHashMap<>(); + private final ExecutorService service = Executors.newSingleThreadExecutor(); + + @OnOpen + public void onOpen(Session session, + @PathParam(value = "clientId") String clientId) throws Exception { + ClientDetail detail = detailMapper.selectById(clientId); + ClientSsh ssh = sshMapper.selectById(clientId); + if(detail == null || ssh == null) { + session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "无法识别此主机")); + return; + } + if(this.createSshConnection(session, ssh, detail.getIp())) { + log.info("主机 {} 的SSH连接已创建", detail.getIp()); + } + } + + @OnMessage + public void onMessage(Session session, String message) throws IOException { + Shell shell = sessionMap.get(session); + OutputStream output = shell.output; + output.write(message.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + @OnClose + public void onClose(Session session) throws IOException { + Shell shell = sessionMap.get(session); + if(shell != null) { + shell.close(); + sessionMap.remove(session); + log.info("主机 {} 的SSH连接已断开", shell.js.getHost()); + } + } + + @OnError + public void onError(Session session, Throwable error) throws IOException { + log.error("用户WebSocket连接出现错误", error); + session.close(); + } + + private boolean createSshConnection(Session session, ClientSsh ssh, String ip) throws IOException{ + try { + JSch jSch = new JSch(); + com.jcraft.jsch.Session js = jSch.getSession(ssh.getUsername(), ip, ssh.getPort()); + js.setPassword(ssh.getPassword()); + js.setConfig("StrictHostKeyChecking", "no"); + js.setTimeout(3000); + js.connect(); + ChannelShell channel = (ChannelShell) js.openChannel("shell"); + channel.setPtyType("xterm"); + channel.connect(1000); + sessionMap.put(session, new Shell(session, js, channel)); + return true; + } catch (JSchException e) { + String message = e.getMessage(); + if(message.equals("Auth fail")) { + session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, + "登录SSH失败,用户名或密码错误")); + log.error("连接SSH失败,用户名或密码错误,登录失败"); + } else if(message.contains("Connection refused")) { + session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, + "连接被拒绝,可能是没有启动SSH服务或是放开端口")); + log.error("连接SSH失败,连接被拒绝,可能是没有启动SSH服务或是放开端口"); + } else { + session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, message)); + log.error("连接SSH时出现错误", e); + } + } + return false; + } + + private class Shell { + private final Session session; + private final com.jcraft.jsch.Session js; + private final ChannelShell channel; + private final InputStream input; + private final OutputStream output; + + public Shell(Session session, com.jcraft.jsch.Session js, ChannelShell channel) throws IOException { + this.js = js; + this.session = session; + this.channel = channel; + this.input = channel.getInputStream(); + this.output = channel.getOutputStream(); + service.submit(this::read); + } + + private void read() { + try { + byte[] buffer = new byte[1024 * 1024]; + int i; + while ((i = input.read(buffer)) != -1) { + String text = new String(Arrays.copyOfRange(buffer, 0, i), StandardCharsets.UTF_8); + session.getBasicRemote().sendText(text); + } + } catch (Exception e) { + log.error("读取SSH输入流时出现问题", e); + } + } + + public void close() throws IOException { + input.close(); + output.close(); + channel.disconnect(); + js.disconnect(); + service.shutdown(); + } + } +} diff --git a/itbaima-monitor-web/package-lock.json b/itbaima-monitor-web/package-lock.json index 726e29d..1869191 100644 --- a/itbaima-monitor-web/package-lock.json +++ b/itbaima-monitor-web/package-lock.json @@ -17,7 +17,9 @@ "pinia": "^2.1.7", "pinia-plugin-persistedstate": "^3.2.0", "vue": "^3.3.4", - "vue-router": "^4.2.4" + "vue-router": "^4.2.4", + "xterm": "^5.3.0", + "xterm-addon-attach": "^0.9.0" }, "devDependencies": { "@vitejs/plugin-vue": "^4.2.3", @@ -1959,6 +1961,19 @@ "integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==", "dev": true }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==" + }, + "node_modules/xterm-addon-attach": { + "version": "0.9.0", + "resolved": "https://registry.npmmirror.com/xterm-addon-attach/-/xterm-addon-attach-0.9.0.tgz", + "integrity": "sha512-NykWWOsobVZPPK3P9eFkItrnBK9Lw0f94uey5zhqIVB1bhswdVBfl+uziEzSOhe2h0rT9wD0wOeAYsdSXeavPw==", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/zrender": { "version": "5.4.4", "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.4.4.tgz", diff --git a/itbaima-monitor-web/package.json b/itbaima-monitor-web/package.json index a6ac19c..ab5acc7 100644 --- a/itbaima-monitor-web/package.json +++ b/itbaima-monitor-web/package.json @@ -17,7 +17,9 @@ "pinia": "^2.1.7", "pinia-plugin-persistedstate": "^3.2.0", "vue": "^3.3.4", - "vue-router": "^4.2.4" + "vue-router": "^4.2.4", + "xterm": "^5.3.0", + "xterm-addon-attach": "^0.9.0" }, "devDependencies": { "@vitejs/plugin-vue": "^4.2.3", diff --git a/itbaima-monitor-web/src/component/Terminal.vue b/itbaima-monitor-web/src/component/Terminal.vue new file mode 100644 index 0000000..236a305 --- /dev/null +++ b/itbaima-monitor-web/src/component/Terminal.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/itbaima-monitor-web/src/component/TerminalWindow.vue b/itbaima-monitor-web/src/component/TerminalWindow.vue index 2459052..8f6f655 100644 --- a/itbaima-monitor-web/src/component/TerminalWindow.vue +++ b/itbaima-monitor-web/src/component/TerminalWindow.vue @@ -2,6 +2,7 @@ import {reactive, ref, watch} from "vue"; import {get, post} from "@/net"; import {ElMessage} from "element-plus"; +import Terminal from "@/component/Terminal.vue"; const props = defineProps({ id: Number @@ -26,6 +27,7 @@ const rules = { ] } +const state = ref(1) const formRef = ref() function saveConnection() { @@ -34,14 +36,13 @@ function saveConnection() { post('/api/monitor/ssh-save', { ...connection, id: props.id - }, () => { - ElMessage.success('正在连接') - }) + }, () => state.value = 2) } }) } watch(() => props.id, id => { + state.value = 1 if(id !== -1) { connection.ip = '' get(`/api/monitor/ssh?clientId=${id}`, data => Object.assign(connection, data)) @@ -51,7 +52,7 @@ watch(() => props.id, id => { diff --git a/itbaima-monitor-web/src/views/main/Manage.vue b/itbaima-monitor-web/src/views/main/Manage.vue index ee736c8..916053d 100644 --- a/itbaima-monitor-web/src/views/main/Manage.vue +++ b/itbaima-monitor-web/src/views/main/Manage.vue @@ -101,7 +101,8 @@ const terminal = reactive({ style="width: 600px;margin: 10px auto" size="320" @open="refreshToken"> -