完成SSH连接操作功能
This commit is contained in:
parent
f06c2fd68f
commit
97ed24f592
@ -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>
|
||||
|
@ -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()
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
17
itbaima-monitor-web/package-lock.json
generated
17
itbaima-monitor-web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
59
itbaima-monitor-web/src/component/Terminal.vue
Normal file
59
itbaima-monitor-web/src/component/Terminal.vue
Normal 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>
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user