实现可运行的xterm.js方案

This commit is contained in:
dushixiang 2021-02-01 00:37:56 +08:00 committed by dushixiang
parent 86ef89ff21
commit 29fb520e48
11 changed files with 165 additions and 58 deletions

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"next-terminal/pkg/utils" "next-terminal/pkg/utils"
"os" "os"
"path"
"time" "time"
) )
@ -26,22 +27,29 @@ type Recorder struct {
timestamp int timestamp int
} }
func NewRecorder(filename string) (recorder *Recorder, err error) { func NewRecorder(dir string) (recorder *Recorder, filename string, err error) {
recorder = &Recorder{} recorder = &Recorder{}
if utils.FileExists(filename) { if utils.FileExists(dir) {
if err := os.RemoveAll(filename); err != nil { if err := os.RemoveAll(dir); err != nil {
return nil, err return nil, "", err
} }
} }
if err = os.MkdirAll(dir, 0777); err != nil {
return
}
filename = path.Join(dir, "recording.cast")
var file *os.File var file *os.File
file, err = os.Create(filename) file, err = os.Create(filename)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
recorder.file = file recorder.file = file
return recorder, nil return recorder, filename, nil
} }
func (recorder *Recorder) Close() { func (recorder *Recorder) Close() {

View File

@ -38,7 +38,13 @@ func SessionPagingEndpoint(c echo.Context) error {
for i := 0; i < len(items); i++ { for i := 0; i < len(items); i++ {
if status == model.Disconnected && len(items[i].Recording) > 0 { if status == model.Disconnected && len(items[i].Recording) > 0 {
recording := items[i].Recording + "/recording"
var recording string
if items[i].Protocol == "rdp" || items[i].Protocol == "vnc" {
recording = items[i].Recording + "/recording"
} else {
recording = items[i].Recording
}
if utils.FileExists(recording) { if utils.FileExists(recording) {
logrus.Debugf("检测到录屏文件[%v]存在", recording) logrus.Debugf("检测到录屏文件[%v]存在", recording)
@ -107,12 +113,14 @@ func CloseSessionById(sessionId string, code int, reason string) {
observable, _ := global.Store.Get(sessionId) observable, _ := global.Store.Get(sessionId)
if observable != nil { if observable != nil {
logrus.Debugf("会话%v创建者退出", observable.Subject.Tunnel.UUID) logrus.Debugf("会话%v创建者退出", observable.Subject.Tunnel.UUID)
_ = observable.Subject.Tunnel.Close() observable.Subject.Close()
for i := 0; i < len(observable.Observers); i++ { for i := 0; i < len(observable.Observers); i++ {
_ = observable.Observers[i].Tunnel.Close() observable.Observers[i].Close()
CloseWebSocket(observable.Observers[i].WebSocket, code, reason) CloseWebSocket(observable.Observers[i].WebSocket, code, reason)
logrus.Debugf("强制踢出会话%v的观察者", observable.Observers[i].Tunnel.UUID) logrus.Debugf("强制踢出会话%v的观察者", observable.Observers[i].Tunnel.UUID)
} }
CloseWebSocket(observable.Subject.WebSocket, code, reason) CloseWebSocket(observable.Subject.WebSocket, code, reason)
} }
global.Store.Del(sessionId) global.Store.Del(sessionId)
@ -529,8 +537,15 @@ func SessionRecordingEndpoint(c echo.Context) error {
if err != nil { if err != nil {
return err return err
} }
recording := path.Join(session.Recording, "recording")
logrus.Debugf("读取录屏文件:%s", recording) var recording string
if session.Protocol == "rdp" || session.Protocol == "vnc" {
recording = session.Recording + "/recording"
} else {
recording = session.Recording
}
logrus.Debugf("读取录屏文件:%v,是否存在: %v, 是否为文件: %v", recording, utils.FileExists(recording), utils.IsFile(recording))
return c.File(recording) return c.File(recording)
} }

View File

@ -10,8 +10,10 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"net/http" "net/http"
"next-terminal/pkg/guacd"
"next-terminal/pkg/model" "next-terminal/pkg/model"
"next-terminal/pkg/utils" "next-terminal/pkg/utils"
"path"
"strconv" "strconv"
"sync" "sync"
"time" "time"
@ -65,7 +67,7 @@ type WindowSize struct {
Rows int `json:"rows"` Rows int `json:"rows"`
} }
func SSHEndpoint(c echo.Context) error { func SSHEndpoint(c echo.Context) (err error) {
ws, err := UpGrader.Upgrade(c.Response().Writer, c.Request(), nil) ws, err := UpGrader.Upgrade(c.Response().Writer, c.Request(), nil)
if err != nil { if err != nil {
logrus.Errorf("升级为WebSocket协议失败%v", err.Error()) logrus.Errorf("升级为WebSocket协议失败%v", err.Error())
@ -149,15 +151,18 @@ func SSHEndpoint(c echo.Context) error {
return err return err
} }
msg := Message{ var recorder *Recorder
Type: Connected, var recording string
Content: "Connect to server successfully.\r\n", property, _ := model.FindPropertyByName(guacd.RecordingPath)
} if property.Value != "" {
_ = WriteMessage(ws, msg) dir := path.Join(property.Value, sessionId)
recorder, recording, err = NewRecorder(dir)
recorder, err := NewRecorder("./" + sessionId + ".cast")
if err != nil { if err != nil {
return err msg := Message{
Type: Closed,
Content: "创建录屏文件失败 :( " + err.Error(),
}
return WriteMessage(ws, msg)
} }
header := &Header{ header := &Header{
@ -173,6 +178,17 @@ func SSHEndpoint(c echo.Context) error {
return err return err
} }
if err := model.UpdateSessionById(&model.Session{Recording: recording}, sessionId); err != nil {
return err
}
}
msg := Message{
Type: Connected,
Content: "Connect to server successfully.\r\n",
}
_ = WriteMessage(ws, msg)
var mut sync.Mutex var mut sync.Mutex
var active = true var active = true
@ -181,7 +197,10 @@ func SSHEndpoint(c echo.Context) error {
mut.Lock() mut.Lock()
if !active { if !active {
logrus.Debugf("会话: %v -> %v 关闭", sshClient.LocalAddr().String(), sshClient.RemoteAddr().String()) logrus.Debugf("会话: %v -> %v 关闭", sshClient.LocalAddr().String(), sshClient.RemoteAddr().String())
if recorder != nil {
recorder.Close() recorder.Close()
}
CloseSessionById(sessionId, Normal, "正常退出")
break break
} }
mut.Unlock() mut.Unlock()
@ -192,8 +211,10 @@ func SSHEndpoint(c echo.Context) error {
} }
if n > 0 { if n > 0 {
s := string(p) s := string(p)
if recorder != nil {
// 录屏 // 录屏
_ = recorder.WriteData(s) _ = recorder.WriteData(s)
}
msg := Message{ msg := Message{
Type: Data, Type: Data,
Content: s, Content: s,

View File

@ -145,6 +145,7 @@ func TunEndpoint(c echo.Context) error {
} }
tun := global.Tun{ tun := global.Tun{
Protocol: configuration.Protocol,
Tunnel: tunnel, Tunnel: tunnel,
WebSocket: ws, WebSocket: ws,
} }

View File

@ -3,16 +3,28 @@ package global
import ( import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/pkg/sftp" "github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"next-terminal/pkg/guacd" "next-terminal/pkg/guacd"
"sync" "sync"
) )
type Tun struct { type Tun struct {
Protocol string
Tunnel *guacd.Tunnel Tunnel *guacd.Tunnel
SshClient *ssh.Client
SftpClient *sftp.Client SftpClient *sftp.Client
WebSocket *websocket.Conn WebSocket *websocket.Conn
} }
func (r *Tun) Close() {
if r.Protocol == "rdp" || r.Protocol == "vnc" {
_ = r.Tunnel.Close()
} else {
_ = r.SshClient.Close()
_ = r.SftpClient.Close()
}
}
type Observable struct { type Observable struct {
Subject *Tun Subject *Tun
Observers []Tun Observers []Tun

38
web/public/asciinema.html Normal file
View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" type="text/css" href="asciinema-player.css"/>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<asciinema-player id='asciinema-player' src=""></asciinema-player>
<script src="asciinema-player.js"></script>
</body>
<script>
const server = 'http://localhost:8088';
function getQueryVariable(variable) {
const query = window.location.search.substring(1);
const vars = query.split("&");
for (let i = 0; i < vars.length; i++) {
const pair = vars[i].split("=");
if (pair[0] === variable) {
return pair[1];
}
}
return false;
}
let sessionId = getQueryVariable('sessionId');
document.getElementById('asciinema-player').setAttribute('src', `${server}/sessions/${sessionId}/recording`);
</script>
</html>

View File

@ -8,6 +8,7 @@ import {getToken, isEmpty} from "../../utils/utils";
import {FitAddon} from 'xterm-addon-fit'; import {FitAddon} from 'xterm-addon-fit';
import "./Access.css" import "./Access.css"
import request from "../../common/request"; import request from "../../common/request";
import {message} from "antd";
class AccessSSH extends Component { class AccessSSH extends Component {
@ -111,8 +112,8 @@ class AccessSSH extends Component {
switch (msg['type']) { switch (msg['type']) {
case 'connected': case 'connected':
term.clear(); term.clear();
console.log(msg['content'])
this.onWindowResize(); this.onWindowResize();
this.updateSessionStatus(sessionId);
break; break;
case 'data': case 'data':
term.write(msg['content']); term.write(msg['content']);
@ -152,6 +153,13 @@ class AccessSSH extends Component {
return result['data']['id']; return result['data']['id'];
} }
updateSessionStatus = async (sessionId) => {
let result = await request.post(`/sessions/${sessionId}/connect`);
if (result['code'] !== 1) {
message.error(result['message']);
}
}
terminalSize() { terminalSize() {
return { return {
cols: Math.floor(this.state.width / 7.5), cols: Math.floor(this.state.width / 7.5),

View File

@ -1,5 +1,4 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import { import {
Button, Button,
Col, Col,
@ -64,6 +63,7 @@ class OfflineSession extends Component {
delBtnLoading: false, delBtnLoading: false,
users: [], users: [],
assets: [], assets: [],
selectedRow: {},
}; };
componentDidMount() { componentDidMount() {
@ -123,10 +123,10 @@ class OfflineSession extends Component {
this.loadTableData(queryParams) this.loadTableData(queryParams)
}; };
showPlayback = (sessionId) => { showPlayback = (row) => {
this.setState({ this.setState({
playbackVisible: true, playbackVisible: true,
playbackSessionId: sessionId selectedRow: row
}); });
}; };
@ -289,7 +289,7 @@ class OfflineSession extends Component {
<div> <div>
<Button type="link" size='small' <Button type="link" size='small'
disabled={disabled} disabled={disabled}
onClick={() => this.showPlayback(record.id)}>回放</Button> onClick={() => this.showPlayback(record)}>回放</Button>
<Button type="link" size='small' onClick={() => { <Button type="link" size='small' onClick={() => {
confirm({ confirm({
title: '您确定要删除此会话吗?', title: '您确定要删除此会话吗?',
@ -488,7 +488,30 @@ class OfflineSession extends Component {
destroyOnClose destroyOnClose
maskClosable={false} maskClosable={false}
> >
<Playback sessionId={this.state.playbackSessionId}/> {
this.state.selectedRow['protocol'] === 'rdp' || this.state.selectedRow['protocol'] === 'vnc' ?
<Playback sessionId={this.state.selectedRow['id']}/>
:
<iframe
style={{
width: '100%',
// height: this.state.iFrameHeight,
overflow: 'visible'
}}
onLoad={() => {
// const obj = ReactDOM.findDOMNode(this);
// this.setState({
// "iFrameHeight": obj.contentWindow.document.body.scrollHeight + 'px'
// });
}}
ref="iframe"
src={'./asciinema.html?sessionId=' + this.state.selectedRow['id']}
width="100%"
height={window.innerHeight * 0.8}
frameBorder="0"
/>
}
</Modal> : undefined </Modal> : undefined
} }

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" type="text/css" href="asciinema-player.css"/>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<asciinema-player src="./903dfd65-838c-453f-9866-eadd5725321b.cast"></asciinema-player>
<script src="asciinema-player.js"></script>
</body>
</html>