编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

springboot+websocket实现基于xterm.js的终端terminal(一)

wxchong 2024-07-05 01:43:25 开源技术 12 ℃ 0 评论

文章总共分为三篇,分别是:

  • springboot+websocket实现基于xterm.js的终端terminal(一)
  • springboot+websocket实现Html端整合Xterm.js实现客户端(二)
  • springboot+websocket实现vue整合Xtermjs实现客户端(三)

请查看本篇文章上下文!!!

什么是Xterm.js

Xterm.js 是一个用 TypeScript 编写的前端组件,它允许应用程序在浏览器中将功能齐全的终端带给用户。 它被 VS Code、Hyper 和 Theia 等流行项目使用。

springboot整合websocket实现服务端

1、引入pom依赖

此处主要引入websocket依赖和其他辅助工具

<!--websocket依赖-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>    
<!--ssh2依赖-->
<dependency>
  <groupId>ch.ethz.ganymed</groupId>
  <artifactId>ganymed-ssh2</artifactId>
  <version>262</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>1.2.79</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.jcraft/jsch -->
<dependency>
  <groupId>com.jcraft</groupId>
  <artifactId>jsch</artifactId>
  <version>0.1.55</version>
</dependency>
<dependency>
  <groupId>cn.hutool</groupId>
  <artifactId>hutool-all</artifactId>
  <version>5.3.7</version>
</dependency>

2、新建SshHandler-websocket处理类

package com.qingfeng.framework.ssh;

import cn.hutool.core.io.IoUtil;
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.ssh.ChannelType;
import cn.hutool.extra.ssh.JschUtil;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.atomic.AtomicInteger;

/**
* @ProjectName SshHandler
* @author qingfeng
* @version 1.0.0
* @Description ssh 处理
* @createTime 2022/5/2 0002 15:26
*/
@ServerEndpoint(value = "/ws/ssh")
@Component
public class SshHandler {
    private static final ConcurrentHashMap<String, HandlerItem> HANDLER_ITEM_CONCURRENT_HASH_MAP = new ConcurrentHashMap<>();
    
    @PostConstruct
    public void init() {
        System.out.println("websocket 加载");
    }
    private static Logger log = LoggerFactory.getLogger(SshHandler.class);
    private static final AtomicInteger OnlineCount = new AtomicInteger(0);
    // concurrent包的线程安全Set,用来存放每个客户端对应的Session对象。
    private static CopyOnWriteArraySet<javax.websocket.Session> SessionSet = new CopyOnWriteArraySet<javax.websocket.Session>();
    
    
    /**
    * 连接建立成功调用的方法
    */
    @OnOpen
    public void onOpen(javax.websocket.Session session) throws Exception {
        SessionSet.add(session);
        SshModel sshItem = new SshModel();
        sshItem.setHost("127.0.0.1");
        sshItem.setPort(22);
        sshItem.setUser("root");
        sshItem.setPassword("root");
        int cnt = OnlineCount.incrementAndGet(); // 在线数加1
        log.info("有连接加入,当前连接数为:{},sessionId={}", cnt,session.getId());
        SendMessage(session, "连接成功,sessionId="+session.getId());
        HandlerItem handlerItem = new HandlerItem(session, sshItem);
        handlerItem.startRead();
        HANDLER_ITEM_CONCURRENT_HASH_MAP.put(session.getId(), handlerItem);
    }
    
    /**
    * 连接关闭调用的方法
    */
    @OnClose
    public void onClose(javax.websocket.Session session) {
        SessionSet.remove(session);
        int cnt = OnlineCount.decrementAndGet();
        log.info("有连接关闭,当前连接数为:{}", cnt);
    }
    
    /**
    * 收到客户端消息后调用的方法
    * @param message
    * 客户端发送过来的消息
    */
    @OnMessage
    public void onMessage(String message, javax.websocket.Session session) throws Exception {
        log.info("来自客户端的消息:{}",message);
        //        SendMessage(session, "收到消息,消息内容:"+message);
        HandlerItem handlerItem = HANDLER_ITEM_CONCURRENT_HASH_MAP.get(session.getId());
        this.sendCommand(handlerItem, message);
    }
    
    /**
    * 出现错误
    * @param session
    * @param error
    */
    @OnError
    public void onError(javax.websocket.Session session, Throwable error) {
        log.error("发生错误:{},Session ID: {}",error.getMessage(),session.getId());
        error.printStackTrace();
    }
    
    private void sendCommand(HandlerItem handlerItem, String data) throws Exception {
        if (handlerItem.checkInput(data)) {
            handlerItem.outputStream.write(data.getBytes());
        } else {
            handlerItem.outputStream.write("没有执行相关命令权限".getBytes());
            handlerItem.outputStream.flush();
            handlerItem.outputStream.write(new byte[]{3});
        }
        handlerItem.outputStream.flush();
    }
    
    /**
    * 发送消息,实践表明,每次浏览器刷新,session会发生变化。
    * @param session
    * @param message
    */
    public static void SendMessage(javax.websocket.Session session, String message) {
        try {
            //            session.getBasicRemote().sendText(String.format("%s (From Server,Session ID=%s)",message,session.getId()));
            session.getBasicRemote().sendText(message);
            session.getBasicRemote().sendText("anxingtao>#34;);
        } catch (IOException e) {
            log.error("发送消息出错:{}", e.getMessage());
            e.printStackTrace();
        }
    }
    
    private class HandlerItem implements Runnable {
        private final javax.websocket.Session session;
        private final InputStream inputStream;
        private final OutputStream outputStream;
        private final Session openSession;
        private final ChannelShell channel;
        private final SshModel sshItem;
        private final StringBuilder nowLineInput = new StringBuilder();
        
        HandlerItem(javax.websocket.Session session, SshModel sshItem) throws IOException {
            this.session = session;
            this.sshItem = sshItem;
            this.openSession = JschUtil.openSession(sshItem.getHost(), sshItem.getPort(), sshItem.getUser(), sshItem.getPassword());
            this.channel = (ChannelShell) JschUtil.createChannel(openSession, ChannelType.SHELL);
            this.inputStream = channel.getInputStream();
            this.outputStream = channel.getOutputStream();
        }
        
        void startRead() throws JSchException {
            this.channel.connect();
            ThreadUtil.execute(this);
        }
        
        
        /**
        * 添加到命令队列
        *
        * @param msg 输入
        * @return 当前待确认待所有命令
        */
        private String append(String msg) {
            char[] x = msg.toCharArray();
            if (x.length == 1 && x[0] == 127) {
                // 退格键
                int length = nowLineInput.length();
                if (length > 0) {
                    nowLineInput.delete(length - 1, length);
                }
            } else {
                nowLineInput.append(msg);
            }
            return nowLineInput.toString();
        }
        
        public boolean checkInput(String msg) {
            String allCommand = this.append(msg);
            boolean refuse;
            if (StrUtil.equalsAny(msg, StrUtil.CR, StrUtil.TAB)) {
                String join = nowLineInput.toString();
                if (StrUtil.equals(msg, StrUtil.CR)) {
                    nowLineInput.setLength(0);
                }
                refuse = SshModel.checkInputItem(sshItem, join);
            } else {
                // 复制输出
                refuse = SshModel.checkInputItem(sshItem, msg);
            }
            return refuse;
        }
        
        
        @Override
        public void run() {
            try {
                byte[] buffer = new byte[1024];
                int i;
                //如果没有数据来,线程会一直阻塞在这个地方等待数据。
                while ((i = inputStream.read(buffer)) != -1) {
                    sendBinary(session, new String(Arrays.copyOfRange(buffer, 0, i), sshItem.getCharsetT()));
                }
            } catch (Exception e) {
                if (!this.openSession.isConnected()) {
                    return;
                }
                
                SshHandler.this.destroy(this.session);
            }
        }
    }
    
    public void destroy(javax.websocket.Session session) {
        HandlerItem handlerItem = HANDLER_ITEM_CONCURRENT_HASH_MAP.get(session.getId());
        if (handlerItem != null) {
            IoUtil.close(handlerItem.inputStream);
            IoUtil.close(handlerItem.outputStream);
            JschUtil.close(handlerItem.channel);
            JschUtil.close(handlerItem.openSession);
        }
        IoUtil.close(session);
        HANDLER_ITEM_CONCURRENT_HASH_MAP.remove(session.getId());
    }
    
    private static void sendBinary(javax.websocket.Session session, String msg) {
        //		if (!session.isOpen()) {
        //			// 会话关闭不能发送消息 @author jzy 21-08-04
        //			return;
        //		}
        
        //		synchronized (session.getId()) {
        //			BinaryMessage byteBuffer = new BinaryMessage(msg.getBytes());
        try {
            System.out.println("#####:"+msg);
            session.getBasicRemote().sendText(msg);
        } catch (IOException e) {
        }
        //		}
    }
}

3、创建SshModel实体类

package com.qingfeng.framework.ssh;

import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.EnumUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONArray;

import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.List;

/**
 * @ProjectName SshModel
 * @author Administrator
 * @version 1.0.0
 * @Description SshModel实体类
 * @createTime 2022/5/2 0002 15:29
 */
public class SshModel {

	private String name;
	private String host;
	private Integer port;
	private String user;
	private String password;
	/**
	 * 编码格式
	 */
	private String charset;

	/**
	 * 文件目录
	 */
	private String fileDirs;

	/**
	 * ssh 私钥
	 */
	private String privateKey;

	private String connectType;

	/**
	 * 不允许执行的命令
	 */
	private String notAllowedCommand;

	/**
	 * 允许编辑的后缀文件
	 */
	private String allowEditSuffix;

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getNotAllowedCommand() {
		return notAllowedCommand;
	}

	public void setNotAllowedCommand(String notAllowedCommand) {
		this.notAllowedCommand = notAllowedCommand;
	}

	public ConnectType connectType() {
		return EnumUtil.fromString(ConnectType.class, this.connectType, ConnectType.PASS);
	}

	public String getConnectType() {
		return connectType;
	}

	public void setConnectType(String connectType) {
		this.connectType = connectType;
	}

	public String getPrivateKey() {
		return privateKey;
	}

	public void setPrivateKey(String privateKey) {
		this.privateKey = privateKey;
	}

	public String getFileDirs() {
		return fileDirs;
	}

	public void setFileDirs(String fileDirs) {
		this.fileDirs = fileDirs;
	}

	public List<String> fileDirs() {
		return StringUtil.jsonConvertArray(this.fileDirs, String.class);
	}

	public void fileDirs(List<String> fileDirs) {
		if (fileDirs != null) {
			for (int i = fileDirs.size() - 1; i >= 0; i--) {
				String s = fileDirs.get(i);
				fileDirs.set(i, FileUtil.normalize(s));
			}
			this.fileDirs = JSONArray.toJSONString(fileDirs);
		} else {
			this.fileDirs = null;
		}
	}

	public String getHost() {
		return host;
	}

	public void setHost(String host) {
		this.host = host;
	}

	public Integer getPort() {
		return port;
	}

	public void setPort(Integer port) {
		this.port = port;
	}

	public String getUser() {
		return user;
	}

	public void setUser(String user) {
		this.user = user;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public String getCharset() {
		return charset;
	}

	public void setCharset(String charset) {
		this.charset = charset;
	}

	public Charset getCharsetT() {
		Charset charset;
		try {
			charset = Charset.forName(this.getCharset());
		} catch (Exception e) {
			charset = CharsetUtil.CHARSET_UTF_8;
		}
		return charset;
	}

	public List<String> allowEditSuffix() {
		return StringUtil.jsonConvertArray(this.allowEditSuffix, String.class);
	}

	public void allowEditSuffix(List<String> allowEditSuffix) {
		if (allowEditSuffix == null) {
			this.allowEditSuffix = null;
		} else {
			this.allowEditSuffix = JSONArray.toJSONString(allowEditSuffix);
		}
	}

	public String getAllowEditSuffix() {
		return allowEditSuffix;
	}

	public void setAllowEditSuffix(String allowEditSuffix) {
		this.allowEditSuffix = allowEditSuffix;
	}

	/**
	 * 检查是否包含禁止命令
	 *
	 * @param sshItem   实体
	 * @param inputItem 输入的命令
	 * @return false 存在禁止输入的命令
	 */
	public static boolean checkInputItem(SshModel sshItem, String inputItem) {
		// 检查禁止执行的命令
		String notAllowedCommand = StrUtil.emptyToDefault(sshItem.getNotAllowedCommand(), StrUtil.EMPTY).toLowerCase();
		if (StrUtil.isEmpty(notAllowedCommand)) {
			return true;
		}
		List<String> split = Arrays.asList(StrUtil.split(notAllowedCommand, StrUtil.COMMA));
		inputItem = inputItem.toLowerCase();
		List<String> commands = Arrays.asList(StrUtil.split(inputItem, StrUtil.CR));
		commands.addAll(Arrays.asList(StrUtil.split(inputItem, "&")));
		for (String s : split) {
			//
			boolean anyMatch = commands.stream().anyMatch(item -> StrUtil.startWithAny(item, s + StrUtil.SPACE, ("&" + s + StrUtil.SPACE), StrUtil.SPACE + s + StrUtil.SPACE));
			if (anyMatch) {
				return false;
			}
			//
			anyMatch = commands.stream().anyMatch(item -> StrUtil.equals(item, s));
			if (anyMatch) {
				return false;
			}
		}
		return true;
	}

	public enum ConnectType {
		/**
		 * 账号密码
		 */
		PASS,
		/**
		 * 密钥
		 */
		PUBKEY
	}
}

4、新建StringUtil工具类

package com.qingfeng.framework.ssh;

import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.StrUtil;
import cn.hutool.system.SystemUtil;
import com.alibaba.fastjson.JSON;

import java.io.File;
import java.util.List;

/**
 * @ProjectName StringUtil
 * @author qingfeng
 * @version 1.0.0
 * @Description 方法运行参数工具
 * @createTime 2022/5/2 0002 15:29
 */
public class StringUtil {

	/**
	 * 支持的压缩包格式
	 */
	public static final String[] PACKAGE_EXT = new String[]{"tar.bz2", "tar.gz", "tar", "bz2", "zip", "gz"};

	/**
	 * 获取启动参数
	 * @param args 所有参数
	 * @param name 参数名
	 * @return 值
	 */
	public static String getArgsValue(String[] args, String name) {
		if (args == null) {
			return null;
		}
		for (String item : args) {
			item = StrUtil.trim(item);
			if (item.startsWith("--" + name + "=")) {
				return item.substring(name.length() + 3);
			}
		}
		return null;
	}

	/**
	 * id输入规则
	 *
	 * @param value 值
	 * @param min   最短
	 * @param max   最长
	 * @return true
	 */
	public static boolean isGeneral(CharSequence value, int min, int max) {
		String reg = "^[a-zA-Z0-9_-]{" + min + StrUtil.COMMA + max + "}#34;;
		return Validator.isMatchRegex(reg, value);
	}

	/**
	 * 删除文件开始的路径
	 *
	 * @param file      要删除的文件
	 * @param startPath 开始的路径
	 * @param inName    是否返回文件名
	 * @return /test/a.txt /test/  a.txt
	 */
	public static String delStartPath(File file, String startPath, boolean inName) {
		String newWhitePath;
		if (inName) {
			newWhitePath = FileUtil.getAbsolutePath(file.getAbsolutePath());
		} else {
			newWhitePath = FileUtil.getAbsolutePath(file.getParentFile());
		}
		String itemAbsPath = FileUtil.getAbsolutePath(new File(startPath));
		itemAbsPath = FileUtil.normalize(itemAbsPath);
		newWhitePath = FileUtil.normalize(newWhitePath);
		String path = StrUtil.removePrefix(newWhitePath, itemAbsPath);
		//newWhitePath.substring(newWhitePath.indexOf(itemAbsPath) + itemAbsPath.length());
		path = FileUtil.normalize(path);
		if (path.startsWith(StrUtil.SLASH)) {
			path = path.substring(1);
		}
		return path;
	}

	/**
	 * 获取jdk 中的tools jar文件路径
	 *
	 * @return file
	 */
	public static File getToolsJar() {
		File file = new File(SystemUtil.getJavaRuntimeInfo().getHomeDir());
		return new File(file.getParentFile(), "lib/tools.jar");
	}

	/**
	 * 指定时间的下一个刻度
	 *
	 * @return String
	 */
	public static String getNextScaleTime(String time, Long millis) {
		DateTime dateTime = DateUtil.parse(time);
		if (millis == null) {
			millis = 30 * 1000L;
		}
		DateTime newTime = dateTime.offsetNew(DateField.SECOND, (int) (millis / 1000));
		return DateUtil.formatTime(newTime);
	}

//	/**
//	 * 删除 yml 文件内容注释
//	 *
//	 * @param content 配置内容
//	 * @return 移除后的内容
//	 */
//	public static String deleteComment(String content) {
//		List<String> split = StrUtil.split(content, StrUtil.LF);
//		split = split.stream().filter(s -> {
//			if (StrUtil.isEmpty(s)) {
//				return false;
//			}
//			s = StrUtil.trim(s);
//			return !StrUtil.startWith(s, "#");
//		}).collect(Collectors.toList());
//		return CollUtil.join(split, StrUtil.LF);
//	}

	/**
	 * json 字符串转 bean,兼容普通json和字符串包裹情况
	 *
	 * @param jsonStr json 字符串
	 * @param cls     要转为bean的类
	 * @param <T>     泛型
	 * @return data
	 */
	public static <T> T jsonConvert(String jsonStr, Class<T> cls) {
		if (StrUtil.isEmpty(jsonStr)) {
			return null;
		}
		try {
			return JSON.parseObject(jsonStr, cls);
		} catch (Exception e) {
			return JSON.parseObject(JSON.parse(jsonStr).toString(), cls);
		}
	}

	/**
	 * json 字符串转 bean,兼容普通json和字符串包裹情况
	 *
	 * @param jsonStr json 字符串
	 * @param cls     要转为bean的类
	 * @param <T>     泛型
	 * @return data
	 */
	public static <T> List<T> jsonConvertArray(String jsonStr, Class<T> cls) {
		try {
			if (StrUtil.isEmpty(jsonStr)) {
				return null;
			}
			return JSON.parseArray(jsonStr, cls);
		} catch (Exception e) {
			Object parse = JSON.parse(jsonStr);
			return JSON.parseArray(parse.toString(), cls);
		}
	}
}

5、创建WebSocketConfig

给spring容器注入这个ServerEndpointExporter对象

package com.qingfeng.framework.configure;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
import org.springframework.web.util.WebAppRootListener;

import javax.servlet.ServletContext;
import javax.servlet.ServletException;

/**
 * @author Administrator
 * @version 1.0.0
 * @ProjectName com.qingfeng
 * @Description WebSocketConfig
 * @createTime 2021年08月10日 16:51:00
 */
@Configuration
@ComponentScan
@EnableAutoConfiguration
public class WebSocketConfig implements ServletContextInitializer {
    /**
     * 给spring容器注入这个ServerEndpointExporter对象
     * 相当于xml:
     * <beans>
     * <bean id="serverEndpointExporter" class="org.springframework.web.socket.server.standard.ServerEndpointExporter"/>
     * </beans>
     * <p>
     * 检测所有带有@serverEndpoint注解的bean并注册他们。
     *
     * @return
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        System.out.println("我被注入了");
        return new ServerEndpointExporter();
    }

    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        servletContext.addListener(WebAppRootListener.class);
        servletContext.setInitParameter("org.apache.tomcat.websocket.textBufferSize","52428800");
        servletContext.setInitParameter("org.apache.tomcat.websocket.binaryBufferSize","52428800");
    }
}

6、修改shiro拦截控制

如果是其他的拦截器,也需要设置请求过滤。

filterChainDefinitionMap.put("/ws/**", "anon");

至此后端整合完毕。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表