完成SSH连接操作功能

This commit is contained in:
柏码の讲师 2023-12-14 18:18:59 +08:00
parent f06c2fd68f
commit 97ed24f592
9 changed files with 272 additions and 7 deletions

View File

@ -100,6 +100,15 @@
<artifactId>influxdb-client-java</artifactId>
<version>6.6.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
</dependencies>
<profiles>

View File

@ -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()

View File

@ -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();
}
}

View File

@ -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<Session, Shell> 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();
}
}
}

View File

@ -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",

View File

@ -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",

View File

@ -0,0 +1,59 @@
<script setup>
import {onBeforeUnmount, onMounted, ref} from "vue";
import {ElMessage} from "element-plus";
import {AttachAddon} from "xterm-addon-attach/src/AttachAddon";
import {Terminal} from "xterm";
import "xterm/css/xterm.css";
const props = defineProps({
id: Number
})
const emits = defineEmits(['dispose'])
const terminalRef = ref()
const socket = new WebSocket(`ws://127.0.0.1:8080/terminal/${props.id}`)
socket.onclose = evt => {
if(evt.code !== 1000) {
ElMessage.warning(`连接失败: ${evt.reason}`)
} else {
ElMessage.success('远程SSH连接已断开')
}
emits('dispose')
}
const attachAddon = new AttachAddon(socket);
const term = new Terminal({
lineHeight: 1.2,
rows: 20,
fontSize: 13,
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
fontWeight: "bold",
theme: {
background: '#000000'
},
//
cursorBlink: true,
cursorStyle: 'underline',
scrollback: 100,
tabStopWidth: 4,
});
term.loadAddon(attachAddon);
onMounted(() => {
term.open(terminalRef.value)
term.focus()
})
onBeforeUnmount(() => {
socket.close()
term.dispose()
})
</script>
<template>
<div ref="terminalRef" class="xterm"/>
</template>
<style scoped>
</style>

View File

@ -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 => {
<template>
<div class="terminal-main">
<div class="login" v-loading="!connection.ip">
<div class="login" v-loading="!connection.ip" v-if="state === 1">
<i style="font-size: 50px" class="fa-solid fa-terminal"></i>
<div style="margin-top: 10px;font-weight: bold;font-size: 20px">服务端连接信息</div>
<el-form style="width: 400px;margin: 20px auto" :model="connection"
@ -73,6 +74,11 @@ watch(() => props.id, id => {
<el-button type="success" @click="saveConnection" plain>立即连接</el-button>
</el-form>
</div>
<div v-if="state === 2">
<div style="overflow: hidden;padding: 0 10px 10px 10px">
<terminal :id="id" @dispose="state = 1"/>
</div>
</div>
</div>
</template>

View File

@ -101,7 +101,8 @@ const terminal = reactive({
style="width: 600px;margin: 10px auto" size="320" @open="refreshToken">
<register-card :token="register.token"/>
</el-drawer>
<el-drawer style="width: 800px" :size="500" direction="btt"
<el-drawer style="width: 800px" :size="520" direction="btt"
@close="terminal.id = -1"
v-model="terminal.show" :close-on-click-modal="false">
<template #header>
<div>
@ -117,6 +118,10 @@ const terminal = reactive({
</template>
<style scoped>
:deep(.el-drawer__header) {
margin-bottom: 10px;
}
:deep(.el-checkbox-group .el-checkbox) {
margin-right: 10px;
}