0


开源堡垒机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.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 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();
    }
}
标签: springboot SSH VNC

本文转载自: https://blog.csdn.net/xuruilll/article/details/131648532
版权归原作者 xuruilll 所有, 如有侵权,请联系我们删除。

“开源堡垒机Guacamole二次开发记录之二”的评论:

还没有评论