开源堡垒机Guacamole二次开发记录之二

这篇主要记录录屏和SFTP的实现。

录屏及视频播放

对于录屏及录屏的播放,因为我们的项目中需要把guacd和java后端分开两台服务器部署,而guacamole的录屏是通过guacd程序录制的。我的要求是在Java后端直接把录好的视频文件通过http前端播放,因此需要把录屏放在Java端的服务器上。 

首先稍微修改一下guacamole-common的源码,添加几个可重载的函数,分别是向前端下发ws消息,向guacd上传前端消息以及ws连接关闭的地方。

GuacamoleWebSocketTunnelEndpoint类的onMessage函数中,添加receiveData(message);

try {
            // Write received message
            writer.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函数用于记录鼠标键盘事件

@Override
    protected 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函数,对将要发送给前端的报文进行拦截处理,重点是最后的几行,把报文记录在一个文件中。

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;

                    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格式的视频文件。

@Override
    protected 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;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/video/**").addResourceLocations("file:"+videoFolder);
        super.addResourceHandlers(registry);
    }
}

这样视频文件直接通过web链接就可以在浏览器中播放。

另外要说明一点的是,默认的guacenc程序转换出来的视频文件在浏览器中是无法播放的,视频的内部格式不对,需要修改一下guacamole-server的源码重新编译一下。

guacamole-server-1.5.1srcguacencguacenc.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 wrong
		if (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+"/"+item
			let obj = {
				path: path,
				folder: item
			}
			paths.push(obj)
			return path }, "")
		// Signal server that data is ready to be received
		stream.sendAck('Ready', Guacamole.Status.Code.SUCCESS);

		// Read stream as JSON
		let reader = new Guacamole.JSONReader(stream);

		// Acknowledge received JSON blobs
		reader.onprogress = function onprogress() {
			stream.sendAck("Received", Guacamole.Status.Code.SUCCESS);
		};

		// Reset contents of directory
		reader.onend = function jsonReady() {
			fileList = []
			// For each received stream name
			var 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 = filename
				if (mimetypes[name] === Guacamole.Object.STREAM_INDEX_MIMETYPE) {
					one.type="folder"
				}
				else {
					one.type="file"
				}
				one.fullpath = name
				fileList.push(one)
			}
		};
	});
},

上传下载

上传下载,首先得搞清楚整体得流程,

通过wireshark抓包,可以查看guacd与java后端的通信报文,

通过浏览器自带的调试工具,可以查看前端和Java后端之间的websocket通信报文,

通过上面两个工具的抓包分析,分析出上传下载的流程。

文件下载流程:

  1. 首先前端通过websocket发送3.get报文,java后端接受到后,直接发往guacd服务端;然后前端再通过http接口发送文件请求到Java端;
  2. guacd回复application/octet-stream报文给Java后端,Java后端直接向guacd端回复ack消息,不向前端转发;
  3. guacd端开始发送4.blob文件段,Java端接收到后,将4.blob报文的实际blob字段截取下来,通过WebSocket向前端回复截取后的报文,同时通过HTTP接口向前端发送实际的文件段;
  4. 前端websocket接受到blob消息后,回复ack,Java端转发给guacd,guacd再发下一段文件,循环这个过程直到文件发送完毕。
  5. 最后guacd端发送end报文,java端通过websocket转发给前端,整个下载过程结束。

文件上传流程:

  1. 前端发送put指令,Java端接收到后,直接转给guacd端
  2. guacd端回复File Opened消息通知文件已准备好,可以写入
  3. 前端通过Http post 发送MultipartFile给java端,java端接收到后转发给guacd端
  4. guacd端回复SFTP OK消息
  5. 前端发送下一段,循环发送直到文件发送完成,最后Java端发送end命令给guacd端
  6. guacd端回复OK消息,整个文件上传流程结束

下面是代码实现的大致流程:

前端下载代码,先通过filesystemObject.requestInputStream发送下载请求,再通过iframe挂一个http get请求开始下载文件,中间通过stream.onblob事件回复Ack消息,通过stream.onend事件结束下载流程

downloadfile(path){
	filesystemObject.requestInputStream(path, function downloadStreamReceived(stream, mimetype) {
		// Parse filename from string
		var filename = path.match(/(.*[\/])?(.*)/)[2];
		var url = '/tunnels/' + uuid + '/sessions/' + stream.index + '/files/' + filename;

		// Create temporary hidden iframe to facilitate download
		var iframe = document.createElement('iframe');
		iframe.style.display = 'none';

		// The iframe MUST be part of the DOM for the download to occur
		document.body.appendChild(iframe);

		iframe.onload = function downloadComplete() {
			document.body.removeChild(iframe);
		};

		// Acknowledge (and ignore) any received blobs
		stream.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 downloads
		stream.onend = function downloadComplete() {
			window.setTimeout(function cleanupIframe() {
				if (iframe.parentElement) {
					document.body.removeChild(iframe);
				}
			}, 5000);
		};

		// Begin download
		iframe.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();
	}
}

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>