这篇主要记录录屏和SFTP的实现。
录屏及视频播放
对于录屏及录屏的播放,因为我们的项目中需要把guacd和java后端分开两台服务器部署,而guacamole的录屏是通过guacd程序录制的。我的要求是在Java后端直接把录好的视频文件通过http前端播放,因此需要把录屏放在Java端的服务器上。
首先稍微修改一下guacamole-common的源码,添加几个可重载的函数,分别是向前端下发ws消息,向guacd上传前端消息以及ws连接关闭的地方。
GuacamoleWebSocketTunnelEndpoint类的onMessage函数中,添加receiveData(message);
try {// Write received messagewriter.write(message.toCharArray());receiveData(message);}catch (GuacamoleConnectionClosedException e) {logger.debug("Connection to guacd closed.", e);}catch (GuacamoleException e) {logger.debug("WebSocket tunnel write failed.", e);}tunnel.releaseWriter();
onClose函数中添加closeConnect函数调用。
public void onClose(Session session, CloseReason closeReason) {try {if (tunnel != null)tunnel.close();closeConnect();}catch (GuacamoleException e) {logger.debug("Unable to close WebSocket tunnel.", e);}}
定义两个可重载的函数
protected void receiveData(String message) {//logger.info("GuacamoleWebSocketTunnelEndpoint-receiveData");}protected void closeConnect() {//logger.info("GuacamoleWebSocketTunnelEndpoint-receiveData");}
在Java工程的WebSocketTunnel类中重载函数
receiveData函数用于记录鼠标键盘事件
@Overrideprotected void receiveData(String message) {//logger.info("WebSocketTunnel-receiveData : " + message);
// try {
// userConnectLogEntity.getBufferedWriter2().write(message);
// userConnectLogEntity.getBufferedWriter2().newLine();
// userConnectLogEntity.getBufferedWriter2().flush();
// } catch (IOException e) {
// throw new RuntimeException(e);
// }}
sendInstruction函数,对将要发送给前端的报文进行拦截处理,重点是最后的几行,把报文记录在一个文件中。
Overrideprotected void sendInstruction(String instruction) throws IOException {if(instruction.startsWith("0.,36.")) {uuid = instruction.substring(6, instruction.length()-1);System.out.println("uuid: "+uuid);TunnelStream tunnelStream = new TunnelStream();tunnelStream.setWebSocketTunnel(this);tunnelStream.setEnd(false);tunnelStream.setBuffer(null);streamMap.tunnelStreamMap.put(uuid, tunnelStream);streamMap.tunnelStreamMap.get(uuid).setOk(false);}else if(instruction.contains("application/octet-stream")) {fileTranfer = true;GuacamoleParser parser = new GuacamoleParser();int parsed;int offset = 0;int length = instruction.toCharArray().length;while (true) {try {if (!((parsed = parser.append(instruction.toCharArray(), offset, length)) != 0))break;}catch (GuacamoleException e) {throw new RuntimeException(e);}offset += parsed;length -= parsed;}GuacamoleInstruction ins = parser.next();synchronized (bufferInstructions) {bufferInstructions.put(ins.getArgs().get(0), ins);}}else if(instruction.contains("17.SFTP: File opened")) {streamMap.tunnelStreamMap.get(uuid).setOk(true);}else if(instruction.contains("8.SFTP: OK")) {streamMap.tunnelStreamMap.get(uuid).setOk(true);}else {if(fileTranfer) {if(instruction.startsWith("4.blob")) {int num1 = instruction.indexOf(",");int num2 = instruction.indexOf(",", num1+1);int num3 = instruction.indexOf(".", num1+1);int id = Integer.parseInt(instruction.substring(num3+1, num2));int num4 = instruction.indexOf(".", num2+1);String str = instruction.substring(num4+1, instruction.length()-1);TunnelStream tunnelStream = streamMap.tunnelStreamMap.get(uuid);if(tunnelStream != null) {synchronized(streamMap) {streamMap.tunnelStreamMap.get(uuid).setBuffer(str);}instruction = instruction.substring(0, num2+1) + "0.;";}}else if(instruction.startsWith("3.end")) {System.out.println("3.end");fileTranfer = false;TunnelStream tunnelStream = streamMap.tunnelStreamMap.get(uuid);synchronized(streamMap) {streamMap.tunnelStreamMap.get(uuid).setEnd(true);}}}}super.sendInstruction(instruction);if(!instruction.startsWith("0.")) {userConnectLogEntity.getBufferedWriter().write(instruction);}}
closeConnect函数,用于ws连接断开时,记录日志,启动线程进行录屏文件的转换。sendInstruction函数中记录了下发的报文,通过调用guacenc程序把日志转换成m4v格式的视频文件。
@Overrideprotected void closeConnect() {try {streamMap.tunnelStreamMap.remove(uuid);userConnectLogEntity.getBufferedWriter().flush();userConnectLogEntity.getBufferedWriter().close();userConnectLogEntity.setEtime(new Date(System.currentTimeMillis()));userConnectLogEntity.setPeriod((int)(userConnectLogEntity.getEtime().getTime()-userConnectLogEntity.getStime().getTime()) / 1000);Thread thread = new MyThread(userConnectLogEntity, userConnectLogService);thread.start();} catch (IOException e) {throw new RuntimeException(e);}}
视频转换线程
public class MyThread extends Thread {private UserConnectLogEntity userConnectLogEntity;private IUserConnectLogService userConnectLogService;public MyThread(UserConnectLogEntity userConnectLogEntity, IUserConnectLogService userConnectLogService) {this.userConnectLogEntity = userConnectLogEntity;this.userConnectLogService = userConnectLogService;}public void run() {try {String fileName = userConnectLogEntity.getVideo().substring(0, userConnectLogEntity.getVideo().length()-4);String str = "guacenc -s 1024x768 -r 300000 -f " + fileName;Process process = Runtime.getRuntime().exec(str);process.waitFor();logger.info("转换视频完成: " + fileName);}catch (Exception e) {logger.error(e.getMessage(), e);}String str = userConnectLogEntity.getVideo();int num1 = str.lastIndexOf(File.separator);int num2 = str.lastIndexOf(File.separator, num1-1);userConnectLogEntity.setVideo("/video"+str.substring(num2));userConnectLogService.updateById(userConnectLogEntity);}}
把视频文件暴露给web端
@Configuration
public class WebAppConfig extends WebMvcConfigurerAdapter {@Value("${fileserver.videofolder}")private String videoFolder;@Overridepublic void addResourceHandlers(ResourceHandlerRegistry registry) {registry.addResourceHandler("/video/**").addResourceLocations("file:"+videoFolder);super.addResourceHandlers(registry);}
}
这样视频文件直接通过web链接就可以在浏览器中播放。
另外要说明一点的是,默认的guacenc程序转换出来的视频文件在浏览器中是无法播放的,视频的内部格式不对,需要修改一下guacamole-server的源码重新编译一下。
guacamole-server-1.5.1\src\guacenc\guacenc.c文件,121行左右,修改一下视频格式重新编译。
//if (guacenc_encode(path, out_path, "mpeg4", width, height, bitrate, force))
// 修改为
if (guacenc_encode(path, out_path, "libx264", width, height, bitrate, force))
SFTP实现
SFTP的实现较为复杂,需要对SFTP上传下载的流程及guacamole封装的协议有较好的了解,才能实现。
文件列表
文件列表相对简单些,通过查看guacamole的前端代码,基本可以了解其流程,自己再按照流程重新写一下前端就行。
实现Guacamole.Client的onfilesystem的响应
guac.onfilesystem = function(object, name) {filesystemObject = object;currentPath = name;listDirectory(currentPath);};
获取文件列表的函数 ,主要是调用filesystemObject.requestInputStream、sendAck
listDirectory(path) {filesystemObject.requestInputStream(path, function handleStream(stream, mimetype) {// Ignore stream if mimetype is wrongif (mimetype !== Guacamole.Object.STREAM_INDEX_MIMETYPE) {stream.sendAck('Unexpected mimetype', Guacamole.Status.Code.UNSUPPORTED);return;}currentPath = path;let exchangePath = path.replace(/^\//,'')folders = exchangePath.length ? exchangePath.split('/') : []paths = []folders.reduce((tmp, item, index) => {let path = tmp+"/"+itemlet obj = {path: path,folder: item}paths.push(obj)return path }, "")// Signal server that data is ready to be receivedstream.sendAck('Ready', Guacamole.Status.Code.SUCCESS);// Read stream as JSONlet reader = new Guacamole.JSONReader(stream);// Acknowledge received JSON blobsreader.onprogress = function onprogress() {stream.sendAck("Received", Guacamole.Status.Code.SUCCESS);};// Reset contents of directoryreader.onend = function jsonReady() {fileList = []// For each received stream namevar mimetypes = reader.getJSON();for (var name in mimetypes) {if (name.substring(0, path.length) !== path){continue;}var filename = name.substring(path.length);if(path.substring(path.length-1) != '/'){filename = name.substring(path.length+1);}let one = {}one.path = filenameif (mimetypes[name] === Guacamole.Object.STREAM_INDEX_MIMETYPE) {one.type="folder"}else {one.type="file"}one.fullpath = namefileList.push(one)}};});
},
上传下载
上传下载,首先得搞清楚整体得流程,
通过wireshark抓包,可以查看guacd与java后端的通信报文,
通过浏览器自带的调试工具,可以查看前端和Java后端之间的websocket通信报文,
通过上面两个工具的抓包分析,分析出上传下载的流程。
文件下载流程:
- 首先前端通过websocket发送3.get报文,java后端接受到后,直接发往guacd服务端;然后前端再通过http接口发送文件请求到Java端;
- guacd回复application/octet-stream报文给Java后端,Java后端直接向guacd端回复ack消息,不向前端转发;
- guacd端开始发送4.blob文件段,Java端接收到后,将4.blob报文的实际blob字段截取下来,通过WebSocket向前端回复截取后的报文,同时通过HTTP接口向前端发送实际的文件段;
- 前端websocket接受到blob消息后,回复ack,Java端转发给guacd,guacd再发下一段文件,循环这个过程直到文件发送完毕。
- 最后guacd端发送end报文,java端通过websocket转发给前端,整个下载过程结束。
文件上传流程:
- 前端发送put指令,Java端接收到后,直接转给guacd端
- guacd端回复File Opened消息通知文件已准备好,可以写入
- 前端通过Http post 发送MultipartFile给java端,java端接收到后转发给guacd端
- guacd端回复SFTP OK消息
- 前端发送下一段,循环发送直到文件发送完成,最后Java端发送end命令给guacd端
- guacd端回复OK消息,整个文件上传流程结束
下面是代码实现的大致流程:
前端下载代码,先通过filesystemObject.requestInputStream发送下载请求,再通过iframe挂一个http get请求开始下载文件,中间通过stream.onblob事件回复Ack消息,通过stream.onend事件结束下载流程
downloadfile(path){filesystemObject.requestInputStream(path, function downloadStreamReceived(stream, mimetype) {// Parse filename from stringvar filename = path.match(/(.*[\\/])?(.*)/)[2];var url = '/tunnels/' + uuid + '/sessions/' + stream.index + '/files/' + filename;// Create temporary hidden iframe to facilitate downloadvar iframe = document.createElement('iframe');iframe.style.display = 'none';// The iframe MUST be part of the DOM for the download to occurdocument.body.appendChild(iframe);iframe.onload = function downloadComplete() {document.body.removeChild(iframe);};// Acknowledge (and ignore) any received blobsstream.onblob = function acknowledgeData() {stream.sendAck('OK', Guacamole.Status.Code.SUCCESS);};// Automatically remove iframe from DOM a few seconds after the stream// ends, in the browser does NOT fire the "load" event for downloadsstream.onend = function downloadComplete() {window.setTimeout(function cleanupIframe() {if (iframe.parentElement) {document.body.removeChild(iframe);}}, 5000);};// Begin downloadiframe.src = url;});
}
前端上传文件代码,file类型input的change事件响应函数。通过filesystemObject.createOutputStream发送文件上传请求,通过XMLHttpRequest post 发送文件给Java端,
changFile(event){let file1 = event.target.files[0];var stream = filesystemObject.createOutputStream(file1.type, currentPath+'/'+file1.name);stream.onack = function beginUpload(status) {if (status.isError()) {return;}}var fd = new FormData();fd.append('file', file1);var url = '/tunnels/' + uuid + '/sessions/' + stream.index;const xhr = new XMLHttpRequest();xhr.open('POST', url);xhr.send(fd);xhr.onreadystatechange = function () {if (xhr.readyState === 4 && xhr.status === 200) {console.log('上传成功');updateDirectory(currentPath);}}
},
接下来是java端的http接口,
下载文件接口,主要是通过ServletOutputStream向前端写文件流。文件流实际是在websocket处理函数中接收的,这儿guacamole通过消息过滤等方式实现了,比较复杂。我这儿简单粗暴的用了全局的公共变量实现,每个websocket实例接受到文件段后,保存到一个公共缓冲区中,再置一个标志位,http controller这儿,循环判断标准位,取出文件段,向前端写文件流。
@GetMapping("/tunnels/{tnid}/sessions/{snid}/files/{filename}")
public void download(@PathVariable("tnid")String tnid, @PathVariable("snid")String snid, @PathVariable("filename")String filename, HttpServletResponse response) {try {System.out.println("download controller: "+tnid);response.setCharacterEncoding("UTF-8");response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8"));ServletOutputStream os = response.getOutputStream();if(streamMap.tunnelStreamMap.get(tnid) != null) {streamMap.tunnelStreamMap.get(tnid).getWebSocketTunnel().startSendFile(snid);streamMap.tunnelStreamMap.get(tnid).setEnd(false);streamMap.tunnelStreamMap.get(tnid).setBuffer(null);long start = System.currentTimeMillis();while(!streamMap.tunnelStreamMap.get(tnid).isEnd()){synchronized(streamMap) {String str = streamMap.tunnelStreamMap.get(tnid).getBuffer();if (str != null) {streamMap.tunnelStreamMap.get(tnid).setBuffer(null);os.write(decoder.decode(str.getBytes()));}}}}os.close();}catch (Exception e) {throw new RuntimeException(e);}
}
上传文件接口。同样通过公共的Bean和websocket线程同步消息
@PostMapping("/tunnels/{tnid}/sessions/{snid}")
public void upload(@RequestParam("file") MultipartFile uploadFile, @PathVariable("tnid")String tnid, @PathVariable("snid")String snid) {try {InputStream inputStream = uploadFile.getInputStream();byte[] buffer = new byte[8192];int bytesRead = 0;while ((bytesRead = inputStream.read(buffer, 0, buffer.length)) != -1) {long start = System.currentTimeMillis();while(!streamMap.tunnelStreamMap.get(tnid).isOk()) {// 等待上传完成消息}streamMap.tunnelStreamMap.get(tnid).setOk(false);System.out.println(bytesRead);byte[] bb = null;if(bytesRead < 8192) {bb = new byte[bytesRead];System.arraycopy(buffer, 0, bb, 0, bytesRead);}else {bb = buffer;}if(streamMap.tunnelStreamMap.get(tnid) != null) {streamMap.tunnelStreamMap.get(tnid).getWebSocketTunnel().sendBlob(snid, bb);}}if(streamMap.tunnelStreamMap.get(tnid) != null) {streamMap.tunnelStreamMap.get(tnid).getWebSocketTunnel().sendEnd(snid);}inputStream.close();}catch (Exception e) {throw new RuntimeException(e);}
}
websocket处理部分,注意和http controller的同步
@Override
protected void sendInstruction(String instruction) throws IOException {if(instruction.startsWith("0.,36.")) {uuid = instruction.substring(6, instruction.length()-1);System.out.println("uuid: "+uuid);TunnelStream tunnelStream = new TunnelStream();tunnelStream.setWebSocketTunnel(this);tunnelStream.setEnd(false);tunnelStream.setBuffer(null);streamMap.tunnelStreamMap.put(uuid, tunnelStream);streamMap.tunnelStreamMap.get(uuid).setOk(false);}else if(instruction.contains("application/octet-stream")) {fileTranfer = true;GuacamoleParser parser = new GuacamoleParser();int parsed;int offset = 0;int length = instruction.toCharArray().length;while (true) {try {if (!((parsed = parser.append(instruction.toCharArray(), offset, length)) != 0))break;}catch (GuacamoleException e) {throw new RuntimeException(e);}offset += parsed;length -= parsed;}GuacamoleInstruction ins = parser.next();synchronized (bufferInstructions) {bufferInstructions.put(ins.getArgs().get(0), ins);}}else if(instruction.contains("17.SFTP: File opened")) {streamMap.tunnelStreamMap.get(uuid).setOk(true);}else if(instruction.contains("8.SFTP: OK")) {streamMap.tunnelStreamMap.get(uuid).setOk(true);}else {if(fileTranfer) {if(instruction.startsWith("4.blob")) {int num1 = instruction.indexOf(",");int num2 = instruction.indexOf(",", num1+1);int num3 = instruction.indexOf(".", num1+1);int id = Integer.parseInt(instruction.substring(num3+1, num2));int num4 = instruction.indexOf(".", num2+1);String str = instruction.substring(num4+1, instruction.length()-1);TunnelStream tunnelStream = streamMap.tunnelStreamMap.get(uuid);if(tunnelStream != null) {synchronized(streamMap) {streamMap.tunnelStreamMap.get(uuid).setBuffer(str);}instruction = instruction.substring(0, num2+1) + "0.;";}}else if(instruction.startsWith("3.end")) {System.out.println("3.end");fileTranfer = false;//int num1 = instruction.indexOf(".", 3);//int id = Integer.parseInt(instruction.substring(num1+1, instruction.length()-1));TunnelStream tunnelStream = streamMap.tunnelStreamMap.get(uuid);synchronized(streamMap) {streamMap.tunnelStreamMap.get(uuid).setEnd(true);}}}}super.sendInstruction(instruction);if(!instruction.startsWith("0.")) {userConnectLogEntity.getBufferedWriter().write(instruction);}
}public void startSendFile(String sid) {acknowledgeStream(sid);
}@Override
protected void receiveData(String message) {
}public void sendBlob(String sid, byte[] bytes) {GuacamoleWriter writer = guacamoleTunnel.acquireWriter();GuacamoleInstruction ins = new GuacamoleInstruction("blob", sid, BaseEncoding.base64().encode(bytes));try {writer.writeInstruction(ins);}catch (GuacamoleException e) {logger.debug("Unable to send \"{}\" for intercepted stream.", ins.getOpcode(), e);}guacamoleTunnel.releaseWriter();
}public void sendEnd(String sid) {GuacamoleWriter writer = guacamoleTunnel.acquireWriter();GuacamoleInstruction ins = new GuacamoleInstruction("end", sid);try {writer.writeInstruction(ins);}catch (GuacamoleException e) {logger.debug("Unable to send \"{}\" for intercepted stream.", ins.getOpcode(), e);}guacamoleTunnel.releaseWriter();
}protected void acknowledgeStream(String sid) {GuacamoleInstruction ins = null;synchronized (bufferInstructions) {ins = bufferInstructions.remove(sid);}if(ins != null) {GuacamoleWriter writer = guacamoleTunnel.acquireWriter();try {writer.writeInstruction(new GuacamoleInstruction("ack", ins.getArgs().get(0), "OK",Integer.toString(GuacamoleStatus.SUCCESS.getGuacamoleStatusCode())));}catch (GuacamoleException e) {throw new RuntimeException(e);}guacamoleTunnel.releaseWriter();}
}