diff --git a/pkg/api/ssh.go b/pkg/api/ssh.go index 579a531..f4ccf27 100644 --- a/pkg/api/ssh.go +++ b/pkg/api/ssh.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "encoding/json" "fmt" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" @@ -46,6 +47,22 @@ func (w *NextWriter) Read() ([]byte, int, error) { return buf, read, err } +const ( + Data = "data" + Resize = "resize" + Closed = "closed" +) + +type Message struct { + Type string `json:"type"` + Content string `json:"content"` +} + +type WindowSize struct { + Height int `json:"height"` + Width int `json:"width"` +} + func SSHEndpoint(c echo.Context) error { ws, err := UpGrader.Upgrade(c.Response().Writer, c.Request(), nil) if err != nil { @@ -60,12 +77,22 @@ func SSHEndpoint(c echo.Context) error { sshClient, err := CreateSshClient(assetId) if err != nil { logrus.Errorf("创建SSH客户端失败:%v", err.Error()) + msg := Message{ + Type: Closed, + Content: err.Error(), + } + err := WriteMessage(ws, msg) return err } session, err := sshClient.NewSession() if err != nil { logrus.Errorf("创建SSH会话失败:%v", err.Error()) + msg := Message{ + Type: Closed, + Content: err.Error(), + } + err := WriteMessage(ws, msg) return err } defer session.Close() @@ -93,15 +120,39 @@ func SSHEndpoint(c echo.Context) error { return err } - go func() { + msg := Message{ + Type: Data, + Content: "Connect to server successfully.", + } + _ = WriteMessage(ws, msg) + var mut sync.Mutex + var active = true + + go func() { for true { + mut.Lock() + if !active { + logrus.Debugf("会话: %v -> %v 关闭", sshClient.LocalAddr().String(), sshClient.RemoteAddr().String()) + break + } + mut.Unlock() + p, n, err := b.Read() if err != nil { continue } if n > 0 { - WriteByteMessage(ws, p) + msg := Message{ + Type: Data, + Content: string(p), + } + message, err := json.Marshal(msg) + if err != nil { + logrus.Warnf("生成Json失败 %v", err) + continue + } + WriteByteMessage(ws, message) } time.Sleep(time.Duration(100) * time.Millisecond) } @@ -110,16 +161,53 @@ func SSHEndpoint(c echo.Context) error { for true { _, message, err := ws.ReadMessage() if err != nil { + // web socket会话关闭后主动关闭ssh会话 + _ = session.Close() + mut.Lock() + active = false + mut.Unlock() + break + } + + var msg Message + err = json.Unmarshal(message, &msg) + if err != nil { + logrus.Warnf("解析Json失败: %v, 原始字符串:%v", err, string(message)) continue } - _, err = stdinPipe.Write(message) - if err != nil { - logrus.Debugf("Tunnel write: %v", err) + + switch msg.Type { + case Resize: + var winSize WindowSize + err = json.Unmarshal([]byte(msg.Content), &winSize) + if err != nil { + logrus.Warnf("解析SSH会话窗口大小失败: %v", err) + continue + } + if err := session.WindowChange(winSize.Height, winSize.Height); err != nil { + logrus.Warnf("更改SSH会话窗口大小失败: %v", err) + continue + } + case Data: + _, err = stdinPipe.Write([]byte(msg.Content)) + if err != nil { + logrus.Debugf("SSH会话写入失败: %v", err) + } } + } return err } +func WriteMessage(ws *websocket.Conn, msg Message) error { + message, err := json.Marshal(msg) + if err != nil { + logrus.Warnf("生成Json失败 %v", err) + } + WriteByteMessage(ws, message) + return err +} + func CreateSshClient(assetId string) (*ssh.Client, error) { asset, err := model.FindAssetById(assetId) if err != nil { @@ -195,10 +283,6 @@ func CreateSshClient(assetId string) (*ssh.Client, error) { return sshClient, nil } -func WriteMessage(ws *websocket.Conn, message string) { - WriteByteMessage(ws, []byte(message)) -} - func WriteByteMessage(ws *websocket.Conn, p []byte) { err := ws.WriteMessage(websocket.TextMessage, p) if err != nil { diff --git a/pkg/api/tunnel.go b/pkg/api/tunnel.go index ca60e4d..2b3dece 100644 --- a/pkg/api/tunnel.go +++ b/pkg/api/tunnel.go @@ -89,6 +89,7 @@ func TunEndpoint(c echo.Context) error { configuration.SetParameter(guacd.DisableBitmapCaching, propertyMap[guacd.DisableBitmapCaching]) configuration.SetParameter(guacd.DisableOffscreenCaching, propertyMap[guacd.DisableOffscreenCaching]) configuration.SetParameter(guacd.DisableGlyphCaching, propertyMap[guacd.DisableGlyphCaching]) + configuration.SetParameter("server-layout", "en-us-qwerty") break case "ssh": if len(session.PrivateKey) > 0 && session.PrivateKey != "-" { diff --git a/web/package-lock.json b/web/package-lock.json index 318ce80..51a0911 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2537,14 +2537,6 @@ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz", "integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA==" }, - "@types/http-proxy": { - "version": "1.17.4", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.4.tgz", - "integrity": "sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==", - "requires": { - "@types/node": "*" - } - }, "@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -7972,18 +7964,6 @@ "requires-port": "^1.0.0" } }, - "http-proxy-middleware": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-1.0.6.tgz", - "integrity": "sha512-NyL6ZB6cVni7pl+/IT2W0ni5ME00xR0sN27AQZZrpKn1b+qRh+mLbBxIq9Cq1oGfmTc7BUq4HB77mxwCaxAYNg==", - "requires": { - "@types/http-proxy": "^1.17.4", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "lodash": "^4.17.20", - "micromatch": "^4.0.2" - } - }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", diff --git a/web/package.json b/web/package.json index 34a10b5..bdf8fe7 100644 --- a/web/package.json +++ b/web/package.json @@ -17,7 +17,8 @@ "react-scripts": "^4.0.0", "typescript": "^3.9.7", "xterm": "^4.9.0", - "xterm-addon-web-links": "^0.4.0" + "xterm-addon-web-links": "^0.4.0", + "xterm-addon-fit": "^0.4.0" }, "scripts": { "start": "react-scripts start", diff --git a/web/src/components/access/Access.js b/web/src/components/access/Access.js index 8dc2328..0fba26c 100644 --- a/web/src/components/access/Access.js +++ b/web/src/components/access/Access.js @@ -28,7 +28,7 @@ import { CloudUploadOutlined, CopyOutlined, DeleteOutlined, - DesktopOutlined, + DesktopOutlined, ExpandOutlined, FileZipOutlined, FolderAddOutlined, LoadingOutlined, @@ -36,7 +36,7 @@ import { UploadOutlined } from '@ant-design/icons'; import Upload from "antd/es/upload"; -import {download, getToken} from "../../utils/utils"; +import {download, exitFull, getToken, requestFullScreen} from "../../utils/utils"; import './Access.css' import Draggable from 'react-draggable'; @@ -87,7 +87,9 @@ class Access extends Component { confirmLoading: false, uploadVisible: false, uploadLoading: false, - startTime: new Date() + startTime: new Date(), + fullScreen: false, + fullScreenBtnText: '进入全屏' }; async componentDidMount() { @@ -345,6 +347,7 @@ class Access extends Component { return true; } + console.log('--------------------') console.log(keysym) this.state.client.sendKeyEvent(1, keysym); if (keysym === 65288) { @@ -370,6 +373,24 @@ class Access extends Component { }); }; + fullScreen = () => { + let fs = this.state.fullScreen; + if(fs){ + exitFull(); + this.setState({ + fullScreen: false, + fullScreenBtnText: '进入全屏' + }) + }else { + requestFullScreen(document.documentElement); + this.setState({ + fullScreen: true, + fullScreenBtnText: '退出全屏' + }) + } + + } + showClipboard = () => { this.setState({ clipboardVisible: true @@ -823,11 +844,18 @@ class Access extends Component { } onClick={this.showFileSystem}> 文件管理 + } onClick={this.fullScreen}> + {this.state.fullScreenBtnText} + }> this.sendCombinationKey(['65507', '65513', '65535'])}>Ctrl+Alt+Delete this.sendCombinationKey(['65507', '65513', '65288'])}>Ctrl+Alt+Backspace + this.sendCombinationKey(['65515', '114'])}>Windows+R + this.sendCombinationKey(['65515'])}>Windows ); @@ -892,22 +920,6 @@ class Access extends Component { - {/*{*/} - {/* this.state.protocol === 'ssh' || this.state.protocol === 'rdp' ?*/} - {/* */} - {/* }*/} - {/* onClick={() => {*/} - {/* this.showFileSystem();*/} - {/* }}*/} - {/* >*/} - {/* */} - {/* */} - {/* : null*/} - {/*}*/} - - { }); + term.onData(data => { + let webSocket = this.state.webSocket; + if (webSocket !== undefined) { + webSocket.send(JSON.stringify({type: 'data', content: data})); + } + }); + let token = getToken(); let webSocket = new WebSocket(wsServer + '/ssh?X-Auth-Token=' + token + '&' + paramStr); - term.loadAddon(new AttachAddon(webSocket)); - this.props.appendWebsocket(webSocket); + + this.props.appendWebsocket({'id': assetId, 'ws': webSocket}); webSocket.onopen = (e => { - term.clear(); - term.focus(); - - if (command !== '') { - webSocket.send(command + String.fromCharCode(13)); - } - + this.onWindowResize(); }); + webSocket.onerror = (e) => { + term.writeln("Failed to connect to server."); + } + webSocket.onclose = (e) => { + term.writeln("Connection is closed."); + } + + let executedCommand = false + webSocket.onmessage = (e) => { + let msg = JSON.parse(e.data); + switch (msg['type']) { + case 'data': + term.write(msg['content']); + break; + case 'closed': + term.writeln(`\x1B[1;3;31m${msg['content']}\x1B[0m `) + webSocket.close(); + break; + } + + if (!executedCommand) { + if (command !== '') { + let webSocket = this.state.webSocket; + if (webSocket !== undefined && webSocket.readyState === WebSocket.OPEN) { + webSocket.send(JSON.stringify({type: 'data', content: command + String.fromCharCode(13)})); + } + } + executedCommand = true; + } + } this.setState({ term: term, - containerWidth: width, - containerHeight: height + fitAddon: fitAddon, + webSocket: webSocket, + width: width, + height: height }); - // window.addEventListener('resize', this.onWindowResize); + window.addEventListener('resize', this.onWindowResize); + } + + componentWillUnmount() { + let webSocket = this.state.webSocket; + if (webSocket) { + webSocket.close() + } } onWindowResize = (e) => { let term = this.state.term; - if (term) { - const [cols, rows] = getGeometry(this.state.containerWidth, this.state.containerHeight); - term.resize(cols, rows); + let fitAddon = this.state.fitAddon; + let webSocket = this.state.webSocket; + if (term && fitAddon && webSocket) { + + let height = term.cols; + let width = term.rows; + + try { + fitAddon.fit(); + } catch (e) { + console.log(e); + } + + term.focus(); + if(webSocket.readyState === WebSocket.OPEN){ + webSocket.send(JSON.stringify({type: 'resize', content: JSON.stringify({height, width})})); + } } }; render() { return (
-
); diff --git a/web/src/components/asset/Asset.js b/web/src/components/asset/Asset.js index f54364b..be35b7b 100644 --- a/web/src/components/asset/Asset.js +++ b/web/src/components/asset/Asset.js @@ -249,6 +249,8 @@ class Asset extends Component { } else { asset['tags'] = asset['tags'].split(','); } + }else { + asset['tags'] = []; } this.setState({ diff --git a/web/src/components/command/BatchCommand.js b/web/src/components/command/BatchCommand.js index 840ed1d..18b9cd9 100644 --- a/web/src/components/command/BatchCommand.js +++ b/web/src/components/command/BatchCommand.js @@ -1,8 +1,10 @@ import React, {Component} from 'react'; -import {List, Card, Input, PageHeader} from "antd"; +import {Card, Input, List, PageHeader, Popconfirm} from "antd"; import Console from "../access/Console"; import {itemRender} from "../../utils/utils"; import Logout from "../user/Logout"; +import './Command.css' + const {Search} = Input; const routes = [ { @@ -25,7 +27,8 @@ class BatchCommand extends Component { state = { webSockets: [], - assets: [] + assets: [], + active: undefined, } componentDidMount() { @@ -66,7 +69,13 @@ class BatchCommand extends Component {
{ for (let i = 0; i < this.state.webSockets.length; i++) { - this.state.webSockets[i].send(value + String.fromCharCode(13)) + let ws = this.state.webSockets[i]['ws']; + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'data', + content: value + String.fromCharCode(13) + })); + } } this.commandRef.current.setValue(''); }} enterButton='执行'/> @@ -78,10 +87,23 @@ class BatchCommand extends Component { dataSource={this.state.assets} renderItem={item => ( - + { + if (this.state.active === item['id']) { + this.setState({ + active: undefined + }) + } else { + this.setState({ + active: item['id'] + }) + } + }} + > diff --git a/web/src/components/command/Command.css b/web/src/components/command/Command.css new file mode 100644 index 0000000..cc9ebec --- /dev/null +++ b/web/src/components/command/Command.css @@ -0,0 +1,4 @@ +.command-active { + box-shadow: 0 0 0 2px #1890FF; + outline: 2px solid #1890FF; +} \ No newline at end of file diff --git a/web/src/utils/utils.js b/web/src/utils/utils.js index f56f8d4..5e94a98 100644 --- a/web/src/utils/utils.js +++ b/web/src/utils/utils.js @@ -78,7 +78,7 @@ export const cloneObj = (obj, ignoreFields) => { export function download(url) { let aElement = document.createElement('a'); aElement.setAttribute('download', ''); - aElement.setAttribute('target', '_blank'); + // aElement.setAttribute('target', '_blank'); aElement.setAttribute('href', url); aElement.click(); } @@ -176,4 +176,37 @@ export function difference(a, b) { let aSet = new Set(a) let bSet = new Set(b) return Array.from(new Set(a.concat(b).filter(v => !aSet.has(v) || !bSet.has(v)))) +} + +export function requestFullScreen(element) { + // 判断各种浏览器,找到正确的方法 + const requestMethod = element.requestFullScreen || //W3C + element.webkitRequestFullScreen || //FireFox + element.mozRequestFullScreen || //Chrome等 + element.msRequestFullScreen; //IE11 + if (requestMethod) { + requestMethod.call(element); + } else if (typeof window.ActiveXObject !== "undefined") { //for Internet Explorer + const wScript = new window.ActiveXObject("WScript.Shell"); + if (wScript !== null) { + wScript.SendKeys("{F11}"); + } + } +} + +//退出全屏 判断浏览器种类 +export function exitFull() { + // 判断各种浏览器,找到正确的方法 + const exitMethod = document.exitFullscreen || //W3C + document.mozCancelFullScreen || //FireFox + document.webkitExitFullscreen || //Chrome等 + document.webkitExitFullscreen; //IE11 + if (exitMethod) { + exitMethod.call(document); + } else if (typeof window.ActiveXObject !== "undefined") { //for Internet Explorer + const wScript = new window.ActiveXObject("WScript.Shell"); + if (wScript !== null) { + wScript.SendKeys("{F11}"); + } + } } \ No newline at end of file