完成SSH远程交互命令行界面

This commit is contained in:
柏码の讲师 2023-12-05 16:06:54 +08:00
parent da711b157b
commit 97e4294ca3
9 changed files with 261 additions and 50 deletions

View File

@ -108,6 +108,11 @@
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.12.0</version>
</dependency>
</dependencies>
<profiles>

View File

@ -1,4 +0,0 @@
package com.example.service;
public interface TerminalService {
}

View File

@ -1,35 +0,0 @@
package com.example.service.impl;
import com.example.service.TerminalService;
import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
@Slf4j
@Service
@ServerEndpoint("/terminal/{clientId}")
public class TerminalServiceImpl implements TerminalService {
@OnOpen
public void onOpen(Session session) throws IOException {
}
@OnClose
public void onClose(Session session) {
}
@OnMessage
public void onMessage(Session session, String message) {
}
@OnError
public void onError(Session session, Throwable error) {
}
}

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.Channel;
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 final Map<Session, Shell> sessionMap = new ConcurrentHashMap<>();
private final ExecutorService service = Executors.newSingleThreadExecutor();
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;
}
@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 Exception {
Shell shell = sessionMap.get(session);
OutputStream output = shell.output;
output.write(message.getBytes());
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 Channel channel;
private final InputStream input;
private final OutputStream output;
public Shell(Session session, com.jcraft.jsch.Session js, Channel 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];
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",
@ -1970,6 +1972,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,58 @@
<script setup>
import {onBeforeUnmount, onMounted, ref} from "vue";
import {Terminal} from "xterm";
import {AttachAddon} from "xterm-addon-attach/src/AttachAddon";
import {ElMessage} from "element-plus";
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

@ -1,10 +1,12 @@
<script setup>
import {reactive, ref, watch} from "vue";
import {get, post} from "@/net";
import {ElMessage} from "element-plus";
import "xterm/css/xterm.css";
import Terminal from "@/component/Terminal.vue";
const props = defineProps({
id: Number
id: Number,
show: Boolean
})
const connection = reactive({
@ -14,6 +16,8 @@ const connection = reactive({
password: ''
})
const state = ref(1)
const rules = {
port: [
{ required: true, message: '请输入端口', trigger: ['blur', 'change'] },
@ -34,22 +38,24 @@ function saveConnection() {
post('/api/monitor/ssh-save', {
...connection,
id: props.id
}, () => {
ElMessage.success('正在连接')
})
}, () => state.value = 2)
}
})
}
watch(() => props.id, id => {
if(id === -1) return
connection.ip = ''
state.value = 1
get(`/api/monitor/ssh?clientId=${id}`, data => Object.assign(connection, data))
}, {immediate: true})
watch(() => props.show, () => state.value = 1)
</script>
<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"
@ -71,6 +77,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

@ -90,8 +90,8 @@ const terminal = reactive({
:size="320" @open="refreshToken">
<register-card :token="register.token"/>
</el-drawer>
<el-drawer style="width: 800px" :size="500" direction="btt" v-model="terminal.show"
:close-on-click-modal="false">
<el-drawer style="width: 800px" :size="520" direction="btt" v-model="terminal.show"
:close-on-click-modal="false" @close="terminal.show = false;terminal.id = -1">
<template #header>
<div>
<div style="font-size: 18px;color: dodgerblue;font-weight: bold;">SSH远程连接</div>
@ -100,12 +100,16 @@ const terminal = reactive({
</div>
</div>
</template>
<terminal-window :id="terminal.id"/>
<terminal-window :id="terminal.id" :show="terminal.show"/>
</el-drawer>
</div>
</template>
<style scoped>
:deep(.el-drawer__header) {
margin-bottom: 10px;
}
:deep(.el-checkbox-group .el-checkbox) {
margin-right: 10px;
}