使用node.js开发webSocket开发代理,可以解决webSocket的cookies跨域问题。
这时有人会说,如果openstack的虚拟桌面流量太大,把代理冲内存溢出了,如何处理?
实际上,不是什么人都特别需要用WEB虚拟桌面操控虚拟机或物理机的,除非是windows系统,linux系统完全可以用流量更小的虚拟终端登陆。实际上进入linux虚拟桌面之后,好多操作不是还要用终端的吗?
业务层面,推荐使用linux系虚拟桌面的用户使用终端,甚至完全不提供虚拟桌面,这才是解决流量拥塞的方法。
然而想要在web上操纵linux终端,就需要通过 SSH 代理的方式调用并返回一个 shell 的虚拟终端(pty)的开源的 Web Terminal 项目。
这里为了防止SSH代理与项目耦合,导致代码难以查找,用node.js中间件或者Java的Springboot实现。
node.js的服务端实现(node.js对于websocket服务端的解决方法有二:原生websocket包和socket.io,后者可以在浏览器不支持的情况下转换为sockJS链接):
var http = require('http');
var io = require('socket.io');
var utf8 = require('utf8');
var SSHClient = require('ssh2').Client;
var server2 = http.createServer(function(request, response) {
console.log((new Date()) + ' Server is reseiveing on port 4041');
response.writeHead(204);
response.end();
});
server2.listen(4041, function() {
console.log((new Date()) + ' Server is listening on port 4041');
});
io = io.listen(server2,{origins: '*:*'});
function createNewServer(machineConfig, socket) {
var ssh = new SSHClient();
let {msgId, ip, username, password, port, rows, cols} = machineConfig;
ssh.on('ready', function () {
socket.emit(msgId, '\r\n***' + ip + ' SSH CONNECTION ESTABLISHED ***\r\n');
ssh.shell(function(err, stream) {
if(rows != null && cols != null)
stream.setWindow(rows, cols);
if(err) {
return socket.emit(msgId, '\r\n*** SSH SHELL ERROR: ' + err.message + ' ***\r\n');
}
socket.on(msgId, function (data) {
var mydata = data.data;
if(mydata != null){
console.log(">>>" + data.data + "<<<");
stream.write(mydata);
}
var size = data.rows;
if(size != null){
stream.setWindow(data.rows, data.cols);
}
});
stream.on('data', function (d) {
try{
var mydata = utf8.decode(d.toString('binary'));
mydata = mydata.replace(/ \r(?!\n)/g,'');
console.log("<<<" + mydata + ">>>");
socket.emit(msgId, mydata);
}catch(err){
socket.emit(msgId, '\r\n*** SSH CONNECTION ERROR: ' + err.message + ' ***\r\n');
}
}).on('close', function () {
ssh.end();
});
})
}).on('close', function () {
socket.emit(msgId, '\r\n*** SSH CONNECTION CLOSED ***\r\n');
ssh.end();
}).on('error', function (err) {
console.log(err);
socket.emit(msgId, '\r\n*** SSH CONNECTION ERROR: ' + err.message + ' ***\r\n');
ssh.end();
}).connect({
host: ip,
port: port,
username: username,
password: password
});
}
io.on('connection', function(socket) {
socket.on('createNewServer', function(machineConfig) {//新建一个ssh连接
console.log("createNewServer");
createNewServer(machineConfig, socket);
})
socket.on('disconnect', function(){
console.log('user disconnected');
});
})
node ssh代理
Vue.js代码这样写(需导入xterm):
<template>
</template>
<script>
import 'xterm/dist/xterm.css'
import { Terminal } from 'xterm';
import * as fit from 'xterm/dist/addons/fit/fit';
import * as fullscreen from 'xterm/dist/addons/fullscreen/fullscreen'
import openSocket from 'socket.io-client';
export default {
name: 'sshweb',
props:['ip','port'],
data () {
return {
wsServer:null,
localip:'',
localport:'',
env: "",
podName: "",
contaName: "",
logtxt: "",
term:[0,0],
colsLen:9,
rowsLen:19,
colRemain:21,
msgId:0,
col:80,
row:24,
terminal: {
pid: 1,
name: 'terminal',
cols: 80,
rows: 24
},
}
},
watch:{
port(val){
this.localport=port;
},
ip(val){
this.localip=ip;
}
},
methods: {
S4() {
return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
},
guid() {
return (this.S4()+this.S4()+"-"+this.S4()+"-"+this.S4()+"-"+this.S4()+"-"+this.S4()+this.S4()+this.S4());
},
createServer1(){
this.msgId = this.guid();
var msgId = this.msgId;
var myserver = this.wsServer;
var selfy = this;
var ipport = this.$route.params.ipport.split(':');
var myport = ipport[0];
var myip = ipport[1];
myserver.emit("createNewServer", {msgId: msgId, ip: myport, username: "root", password: "xunfang", port: myip, rows: this.term[0].rows, cols: this.term[0].cols});
let term = this.term[0];
term.on("data", function(data) {
myserver.emit(msgId, {'data':data});
});
myserver.on(msgId, function (data) {
term.write(data);
});
term.attachCustomKeyEventHandler(function(ev) {
if (ev.keyCode == 86 && ev.ctrlKey) {
myserver.emit(msgId, new TextEncoder().encode("\x00" + this.copy));
}
});
myserver.on('connect_error', function(data){
console.log(data + ' - connect_error');
});
myserver.on('connect_timeout', function(data){
console.log(data + ' - connect_timeout');
});
myserver.on('error', function(data){
console.log(data + ' - error');
});
myserver.on('disconnect', function(data){
console.log(data + ' - disconnect');
});
myserver.on('reconnect', function(data){
console.log(data + ' - reconnect');
});
myserver.on('reconnect_attempt', function(data){
console.log(data + ' - reconnect_attempt');
});
myserver.on('reconnecting', function(data){
console.log(data + ' - reconnecting');
});
myserver.on('reconnect_error', function(data){
console.log(data + ' - reconnect_error');
});
myserver.on('reconnect_failed', function(data){
console.log(data + ' - reconnect_failed');
});
myserver.on('ping', function(data){
console.log(data + ' - ping');
});
myserver.on('pong', function(data){
console.log(data + ' - pong');
});
},
resize(row,col){
row = Math.floor(row/this.rowsLen);
col = Math.floor((col-this.colRemain)/this.colsLen);
if(row<24)row=24;
if(col<80)col=80;
if(this.row != row || this.col != col){
this.row=row;
this.col=col;
this.term[0].fit();
//this.term[0].resize(col,row);
this.wsServer.emit(this.msgId, {'rows':this.term[0].rows.toString(),'cols':this.term[0].cols.toString()});
//this.wsServer.emit(this.msgId, {'rows':row.toString(),'cols':col.toString()});
}
}
},
mounted(){
this.wsServer = new openSocket('ws://127.0.0.1:4041');
var selfy = this;
window.onload = function(){
for(var i = 0;i < 1;i++){
var idname = 'net0';
Terminal.applyAddon(fit);
Terminal.applyAddon(fullscreen);
var terminalContainer = document.getElementById('app');
//terminalContainer.style.height = (selfy.rowsLen * selfy.terminal.rows).toString() + 'px' ;
//terminalContainer.style.width = (selfy.colsLen * selfy.terminal.cols + selfy.colRemain).toString() + 'px' ;
selfy.term = new Terminal({
cursorBlink: true
});
selfy.term.open(terminalContainer, true);
if(window.innerWidth > 0 && window.innerHeight > 0)
selfy.term.fit();
selfy.createServer1();
}
}
$(window).resize(() =>{
if(window.innerWidth > 0 && window.innerHeight > 0){
selfy.resize(window.innerHeight, window.innerWidth);
}
});
},
components: {
}
}
</script>
<style scoped>
#app{height:100%;width:100%;}
</style>
Vue.js的ssh客户端
打开服务器和Vue系统,登陆这个客户端上SSH,大功告成!
Java部分:
这里有个完整的解决方案,在我改正过的github仓库里。
这里说一下websocket连接代码:
vip.r0n9.ws.WebSshHandler:
package vip.r0n9.ws;
import com.jcraft.jsch.JSchException;
import org.springframework.stereotype.Component;
import vip.r0n9.util.SshSession;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ServerEndpoint(value = "/ssh/{id}", configurator = WebSocketConfigrator.class)
@Component
public class WebSshHandler {
private static Map<String, SshSession> map = new ConcurrentHashMap<String, SshSession>();
@OnOpen
public void onOpen(final Session session, @PathParam("id") String id) throws JSchException, IOException, EncodeException, InterruptedException {
System.out.println("有新链接 " + session.getUserProperties().get("ClientIP") + " 加入!当前在线人数为" + getOnlineCount());
Map<String,String> parammap = new HashMap<String,String>();
String[] param = session.getQueryString().split("&");
for(String keyvalue:param){
String[] pair = keyvalue.split("=");
if(pair.length==2){
parammap.put(pair[0], pair[1]);
}
}
String hostname = parammap.get("hostname");
String password = parammap.get("password");
Integer port,cols,rows;
try {
port = Integer.valueOf(parammap.get("port"));
}catch(Exception e) {
port = 22;
}
String username = parammap.get("username");
try {
rows = Integer.valueOf(parammap.get("rows"));
}catch(Exception e) {
rows = 24;
}
try {
cols = Integer.valueOf(parammap.get("cols"));
}catch(Exception e) {
cols = 80;
}
SshSession sshSession;
sshSession = new SshSession(hostname, port, username, password, session, rows, cols);
map.put(session.getId(), sshSession);
}
@OnClose
public void onClose(Session session) {
SshSession sshsession = map.remove(session.getId());
sshsession.close();
}
@OnMessage
public void onMessage(String message, Session session) throws IOException, JSchException {
map.get(session.getId()).getMessage(message);
}
@OnError
public void onError(Session session, Throwable throwable) {
throwable.printStackTrace();
try {
session.getBasicRemote().sendText(throwable.getMessage());
} catch (IOException e) {
e.printStackTrace();
}
}
public static synchronized int getOnlineCount() {
return map.size();
}
}
WebSshController.java
这里就是websocket服务端代码,连接websocket首先要在这里进行处理。
vip.r0n9.util.SshSession:
package vip.r0n9.util;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.nio.ByteBuffer;
import java.util.Iterator;
import javax.websocket.Session;
import com.fasterxml.jackson.databind.JsonNode;
import com.jcraft.jsch.Channel;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import vip.r0n9.JsonUtil;
import vip.r0n9.ws.WebSshHandler;
public class SshSession {
private Session websession;//从客户端发起的websocket连接
private StringBuilder dataToDst = new StringBuilder();
private JSch jsch = new JSch();//ssh客户端
private com.jcraft.jsch.Session jschSession;//ssh服务端返回的单个客户端连接
private ChannelShell channel;
private InputStream inputStream;
private BufferedReader stdout;
private OutputStream outputStream;
private PrintWriter printWriter;
public SshSession() {}
public SshSession(String hostname,int port,String username, String password, final Session session2, int rows, int cols) throws JSchException, IOException {
this.websession = session2;
jschSession = jsch.getSession(username, hostname, port);
jschSession.setPassword(password);
java.util.Properties config = new java.util.Properties();
config.put("StrictHostKeyChecking", "no");
jschSession.setConfig(config);
jschSession.connect();
channel = (ChannelShell) jschSession.openChannel("shell");
channel.setPty(true);
channel.setPtyType("xterm");
channel.setPtySize(cols, rows, cols*8, rows*16);
inputStream = channel.getInputStream();
outputStream = channel.getOutputStream();
printWriter = new PrintWriter(outputStream,false);
channel.connect();
outputStream.write("\r".getBytes());
outputStream.flush();
//这里可以用newFixedThreadPool线程池,可以更方便管理线程
Thread thread = new Thread() {
@Override
public void run() {
try {
byte[] byteset = new byte[3072];
int res = inputStream.read(byteset);
if(res == -1)res = 0;
while (session2 != null && session2.isOpen()) { // 这里会阻塞,所以必须起线程来读取channel返回内容
ByteBuffer byteBuffer = ByteBuffer.wrap(byteset, 0, res);
synchronized (this) {
if(res != 0)
session2.getBasicRemote().sendBinary(byteBuffer);
}
res = inputStream.read(byteset);
if(res == -1)res = 0;
}
} catch (IOException e) {
e.printStackTrace();
}
}
};
thread.start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void close() {
channel.disconnect();
jschSession.disconnect();
try {
this.websession.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
this.inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
this.outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
public void getMessage(String message) throws IOException, JSchException {
Session mysession = this.websession;
System.out.println("来自客户端 " + mysession.getUserProperties().get("ClientIP") + " 的消息:" + message);
JsonNode node = JsonUtil.strToJsonObject(message);
if (node.has("resize")) {
Iterator<JsonNode> myiter = node.get("resize").elements();
int col = myiter.next().asInt();
int row = myiter.next().asInt();
channel.setPtySize(col, row, col*8, row*16);
return;
}
if (node.has("data")) {
String str = node.get("data").asText();
outputStream.write(str.getBytes("utf-8"));
outputStream.flush();
return;
}
}
public StringBuilder getDataToDst() {
return dataToDst;
}
public OutputStream getOutputStream() {
return outputStream;
}
}
SshSession.java
代理SSH客户端的核心逻辑在此,这里要注意不要用Reader和Writer,一些终端功能会无法运行。
下载项目,开启Springboot,在浏览器上访问http://localhost:10003/,会进入登录页面,目前不支持RSA秘钥登录,只支持账号密码登录。
客户端也可以用Vue.js实现:
<template>
</template>
<script>
import 'xterm/dist/xterm.css'
import { Terminal } from 'xterm';
import * as fit from 'xterm/dist/addons/fit/fit';
import * as fullscreen from 'xterm/dist/addons/fullscreen/fullscreen'
import openSocket from 'socket.io-client';
export default {
name: 'sshweb',
props:['ip','port'],
data () {
return {
wsServer:null,
localip:'',
localport:'',
env: "",
podName: "",
contaName: "",
logtxt: "",
term:[0,0],
colsLen:9,
rowsLen:19,
colRemain:21,
msgId:0,
col:80,
row:24,
terminal: {
pid: 1,
name: 'terminal',
cols: 80,
rows: 24
},
}
},
watch:{
port(val){
this.localport=port;
},
ip(val){
this.localip=ip;
}
},
methods: {
S4() {
return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
},
guid() {
return (this.S4()+this.S4()+"-"+this.S4()+"-"+this.S4()+"-"+this.S4()+"-"+this.S4()+this.S4()+this.S4());
},
createServer1(){
this.msgId = this.guid();
var msgId = this.msgId;
var selfy = this;
var ipport = this.$route.params.ipport.split(':');
var myport = ipport[1];
var myip = ipport[0];
var wsurl = 'ws://127.0.0.1:10003/ssh/1?hostname=' + myip + '&port=' + myport + '&username=root&password=xunfang';
this.wsServer = new WebSocket(wsurl);
var myserver = this.wsServer;
let term = this.term[0];
term.on("data", function(data) {
var you = data;
if(you.length > 1)you = you[0];
console.log(you.charCodeAt());
myserver.send(JSON.stringify({'data': data}));
});
myserver.onopen = function(evt) {
console.log(evt);
};
myserver.onmessage = function(msg) {
var reader = new window.FileReader();
var isend = false;
reader.onloadend = function(){
var decoder = new window.TextDecoder('utf-8');
console.log(decoder);
var text = decoder.decode(reader.result);
console.log(text);
term.write(text);
};
reader.readAsArrayBuffer(msg.data);
};
term.attachCustomKeyEventHandler(function(ev) {
if (ev.keyCode == 86 && ev.ctrlKey) {
myserver.send(JSON.stringify({'data': new TextEncoder().encode("\x00" + this.copy)}));
}
});
},
resize(row,col){
row = Math.floor(row/this.rowsLen);
col = Math.floor((col-this.colRemain)/this.colsLen);
if(row<24)row=24;
if(col<80)col=80;
if(this.row != row || this.col != col){
this.row=row;
this.col=col;
this.term[0].fit();
myserver.send(JSON.stringify({'resize': [cols, rows]}));
}
}
},
mounted(){
var selfy = this;
window.onload = function(){
for(var i = 0;i < 1;i++){
var idname = 'net0';
Terminal.applyAddon(fit);
Terminal.applyAddon(fullscreen);
var terminalContainer = document.getElementById('app');
selfy.term = new Terminal({
cursorBlink: true
});
selfy.term.open(terminalContainer, true);
if(window.innerWidth > 0 && window.innerHeight > 0)
selfy.term.fit();
selfy.createServer1();
}
}
$(window).resize(() =>{
if(window.innerWidth > 0 && window.innerHeight > 0){
selfy.resize(window.innerHeight, window.innerWidth);
}
});
},
components: {
}
}
</script>
<style scoped>
#app{height:100%;width:100%;}
</style>
sshClient.vue
两个项目的SSH窗口都是全屏的,只要窗口不小于某个大小,窗口的字会随着窗口缩放而调整位置。
虚拟终端演示:
|