Go語言:xterm.js-websocket Web終端堡壘機
1.前言
因為公司業務需要在自己的私有云伺服器上新增新增WebSsh終端,同時提供輸入命令審計功能.
從google上可以瞭解到xterm.js是一個非常出色的web終端庫,包括VSCode很多成熟的產品都使用這個前端庫.使用起來也比較簡單.
難點是怎麼把ssh命令列轉換成websocket通訊,來提供Stdin,stdout輸出到xterm.js中,接下來就詳解技術細節.
全部程式碼都可以在我的Github.com/dejavuzhou/felix中可以查閱到.
2.知識儲備
- linux下載stdin,stdou和stderr簡單概念
- 熟悉Golang官方庫golang.org/x/crypto/ssh
- 瞭解gorilla/websocket的基本用法
- gin-gonic/gin,當然你也可以使用其他的路由包替代,或者直接使用標準庫
- (前端)websocket
- (前端)xterm.js
3.資料邏輯圖
Golang堡壘機主要功能就是把SSH協議資料使用websocket協議轉發給xterm.js瀏覽器.
堡壘機Golang服務UML
4.程式碼實現
4.1建立gin Handler func
註冊gin路由 api.GET("ws/:id", internal.WsSsh)
package internal
import (
"bytes"
"github.com/dejavuzhou/felix/flx"
"github.com/dejavuzhou/felix/models"
"github.com/dejavuzhou/felix/utils"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/sirupsen/logrus"
"net/http"
"strconv"
"time"
)
var upGrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024 * 1024 * 10,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// handle webSocket connection.
// first,we establish a ssh connection to ssh server when a webSocket comes;
// then we deliver ssh data via ssh connection between browser and ssh server.
// That is, read webSocket data from browser (e.g. 'ls' command) and send data to ssh server via ssh connection;
// the other hand, read returned ssh data from ssh server and write back to browser via webSocket API.
func WsSsh(c *gin.Context) {
v, ok := c.Get("user")
if !ok {
logrus.Error("jwt token can't find auth user")
return
}
userM, ok := v.(*models.User)
if !ok {
logrus.Error("context user is not a models.User type obj")
return
}
cols, err := strconv.Atoi(c.DefaultQuery("cols", "120"))
if wshandleError(c, err) {
return
}
rows, err := strconv.Atoi(c.DefaultQuery("rows", "32"))
if wshandleError(c, err) {
return
}
idx, err := parseParamID(c)
if wshandleError(c, err) {
return
}
mc, err := models.MachineFind(idx)
if wshandleError(c, err) {
return
}
client, err := flx.NewSshClient(mc)
if wshandleError(c, err) {
return
}
defer client.Close()
startTime := time.Now()
ssConn, err := utils.NewSshConn(cols, rows, client)
if wshandleError(c, err) {
return
}
defer ssConn.Close()
// after configure, the WebSocket is ok.
wsConn, err := upGrader.Upgrade(c.Writer, c.Request, nil)
if wshandleError(c, err) {
return
}
defer wsConn.Close()
quitChan := make(chan bool, 3)
var logBuff = new(bytes.Buffer)
// most messages are ssh output, not webSocket input
go ssConn.ReceiveWsMsg(wsConn, logBuff, quitChan)
go ssConn.SendComboOutput(wsConn, quitChan)
go ssConn.SessionWait(quitChan)
<-quitChan
//write logs
xtermLog := models.TermLog{
EndTime: time.Now(),
StartTime: startTime,
UserId: userM.ID,
Log: logBuff.String(),
MachineId: idx,
MachineName: mc.Name,
MachineIp: mc.Ip,
MachineHost: mc.Host,
UserName: userM.Username,
}
err = xtermLog.Create()
if wshandleError(c, err) {
return
}
logrus.Info("websocket finished")
}
程式碼詳解
- 31~52行使用gin來獲取url中的引數(js websocket庫)只可以把引數定義到cookie和和url-query中,所以這裡包括token(不是在header-Authorization中)在內的引數全部在url中獲取
- 53~56行到資料庫中獲取儲存的ssh連線資訊
- 57~68行建立ssh-session
- 69~74行升級得到websocketConn(Reader/Writer)
- 75~85行(核心程式碼)ssh Session 和 websocket 資訊進行交換和處理,同時處理好執行緒退出
- 86~104行處理ssh輸入命令(logBuff),當session結束的時候技術輸入的命令到資料庫中,提供日後審計只用
4.1.1 func NewSshConn(cols, rows int, sshClient *ssh.Client) (*SshConn, error)
建立ssh-session-pty
I 獲取stdin pipline stdinP, err := sshSession.StdinPipe()
II 初始化wsBufferWriter,賦值給ssh-session.Stdout和ssh-session.Stderr
type wsBufferWriter struct {
buffer bytes.Buffer
mu sync.Mutex
}
...
...
...
comboWriter := new(wsBufferWriter)
//ssh.stdout and stderr will write output into comboWriter
sshSession.Stdout = comboWriter
sshSession.Stderr = comboWriter
現在comboWriter就是sshSession的stdout和stderr,可以通過comboWriter獲取ssh輸出
4.2 第75~85行核心程式碼解析
4.2.1 quitChan 用來處理 for select loop退出,程式碼示例
for {
select {
case <-quitChan:
//exit loop
return
default:
fmt.Println("do some stuff")
}
}
4.2.2 var logBuff = new(bytes.Buffer)
暫存session中的stdin命令,websocket session 結束之後,獲取logBuff.String()
,寫入資料庫
Log: logBuff.String(),
...
<-quitChan
//write logs
xtermLog := models.TermLog{
EndTime: time.Now(),
StartTime: startTime,
UserId: userM.ID,
Log: logBuff.String(),
MachineId: idx,
MachineName: mc.Name,
MachineIp: mc.Ip,
MachineHost: mc.Host,
UserName: userM.Username,
}
err = xtermLog.Create()
if wshandleError(c, err) {
return
}
...
4.2.3 go ssConn.ReceiveWsMsg(wsConn, logBuff, quitChan)
處理ws訊息並轉發給ssh-Session stdinPipe,同時暫存訊息到logBuff
//ReceiveWsMsg receive websocket msg do some handling then write into ssh.session.stdin
func (ssConn *SshConn) ReceiveWsMsg(wsConn *websocket.Conn, logBuff *bytes.Buffer, exitCh chan bool) {
//tells other go routine quit
defer setQuit(exitCh)
for {
select {
case <-exitCh:
return
default:
//read websocket msg
_, wsData, err := wsConn.ReadMessage()
if err != nil {
logrus.WithError(err).Error("reading webSocket message failed")
return
}
//unmashal bytes into struct
msgObj := wsMsg{}
if err := json.Unmarshal(wsData, &msgObj); err != nil {
logrus.WithError(err).WithField("wsData", string(wsData)).Error("unmarshal websocket message failed")
}
switch msgObj.Type {
case wsMsgResize:
//handle xterm.js size change
if msgObj.Cols > 0 && msgObj.Rows > 0 {
if err := ssConn.Session.WindowChange(msgObj.Rows, msgObj.Cols); err != nil {
logrus.WithError(err).Error("ssh pty change windows size failed")
}
}
case wsMsgCmd:
//handle xterm.js stdin
decodeBytes, err := base64.StdEncoding.DecodeString(msgObj.Cmd)
if err != nil {
logrus.WithError(err).Error("websock cmd string base64 decoding failed")
}
if _, err := ssConn.StdinPipe.Write(decodeBytes); err != nil {
logrus.WithError(err).Error("ws cmd bytes write to ssh.stdin pipe failed")
}
//write input cmd to log buffer
if _, err := logBuff.Write(decodeBytes); err != nil {
logrus.WithError(err).Error("write received cmd into log buffer failed")
}
}
}
}
}
_, wsData, err := wsConn.ReadMessage()
讀取websocket 傳送的訊息-
if err := json.Unmarshal(wsData, &msgObj); err != nil {
序列化訊息,訊息結構必須前端xterm.js-websocket協商一直,建議使用const ( wsMsgCmd = "cmd"//處理ssh命令 wsMsgResize = "resize"//處理xterm.js dom尺寸變化事件,詳解xterm.js文件 ) type wsMsg struct { Type string `json:"type"` Cmd string `json:"cmd"` Cols int `json:"cols"` Rows int `json:"rows"` }
case wsMsgResize
處理xterm.js 終端尺寸變化事件wsMsgCmd
處理xterm.js 命令輸入if _, err := ssConn.StdinPipe.Write(decodeBytes); err != nil {
把ws xterm.js,前端input命令寫入到ssh-session-stdin-pipline ssh.seesion 如果檢測到到 decodeBytes 包含執行符('\r'),sshSession會執行命令,包把執行結果輸出到comboWriterif _, err := logBuff.Write(decodeBytes); err != nil {
把ws.xterm.js 前端input命令記錄到 logBuff
4.2.4 go ssConn.SendComboOutput(wsConn, quitChan)
把ssh.Session的comboWriter中的資料每隔120ms 通過呼叫websocketConn.WriteMessage
方法返回給xterm.js+websocketClient 前端
func (ssConn *SshConn) SendComboOutput(wsConn *websocket.Conn, exitCh chan bool) {
//tells other go routine quit
defer setQuit(exitCh)
//every 120ms write combine output bytes into websocket response
tick := time.NewTicker(time.Millisecond * time.Duration(120))
//for range time.Tick(120 * time.Millisecond){}
defer tick.Stop()
for {
select {
case <-tick.C:
//write combine output bytes into websocket response
if err := flushComboOutput(ssConn.ComboOutput, wsConn); err != nil {
logrus.WithError(err).Error("ssh sending combo output to webSocket failed")
return
}
case <-exitCh:
return
}
}
}
...
...
...
//flushComboOutput flush ssh.session combine output into websocket response
func flushComboOutput(w *wsBufferWriter, wsConn *websocket.Conn) error {
if w.buffer.Len() != 0 {
err := wsConn.WriteMessage(websocket.TextMessage, w.buffer.Bytes())
if err != nil {
return err
}
w.buffer.Reset()
}
return nil
}
4.2.5 go ssConn.SessionWait(quitChan)
注意這裡的go 關鍵字不能去掉,否在導致不能處理quitChan,導致協程洩露.
func (ssConn *SshConn) SessionWait(quitChan chan bool) {
if err := ssConn.Session.Wait(); err != nil {
logrus.WithError(err).Error("ssh session wait failed")
setQuit(quitChan)
}
}
4.前端vuejs.demo程式碼
可以提供給前端開發人員參考,當然可以讓他直接查xterm.js官方文件,但是websocket 資料庫結構必須前後端協商一致
<template>
<el-dialog :visible.sync="v"
:title="obj.user + '@' + obj.host"
@opened="doOpened"
@open="doOpen"
@close="doClose"
center
fullscreen
>
<div ref="terminal"></div>
</el-dialog>
</template>
<script>
import {Terminal} from "xterm";
import * as fit from "xterm/lib/addons/fit/fit";
import {Base64} from "js-base64";
import * as webLinks from "xterm/lib/addons/webLinks/webLinks";
import * as search from "xterm/lib/addons/search/search";
import "xterm/lib/addons/fullscreen/fullscreen.css";
import "xterm/dist/xterm.css"
import config from "@/config/config"
let defaultTheme = {
foreground: "#ffffff",
background: "#1b212f",
cursor: "#ffffff",
selection: "rgba(255, 255, 255, 0.3)",
black: "#000000",
brightBlack: "#808080",
red: "#ce2f2b",
brightRed: "#f44a47",
green: "#00b976",
brightGreen: "#05d289",
yellow: "#e0d500",
brightYellow: "#f4f628",
magenta: "#bd37bc",
brightMagenta: "#d86cd8",
blue: "#1d6fca",
brightBlue: "#358bed",
cyan: "#00a8cf",
brightCyan: "#19b8dd",
white: "#e5e5e5",
brightWhite: "#ffffff"
};
let bindTerminalResize = (term, websocket) => {
let onTermResize = size => {
websocket.send(
JSON.stringify({
type: "resize",
rows: size.rows,
cols: size.cols
})
);
};
// register resize event.
term.on("resize", onTermResize);
// unregister resize event when WebSocket closed.
websocket.addEventListener("close", function () {
term.off("resize", onTermResize);
});
};
let bindTerminal = (term, websocket, bidirectional, bufferedTime) => {
term.socket = websocket;
let messageBuffer = null;
let handleWebSocketMessage = function (ev) {
if (bufferedTime && bufferedTime > 0) {
if (messageBuffer) {
messageBuffer += ev.data;
} else {
messageBuffer = ev.data;
setTimeout(function () {
term.write(messageBuffer);
}, bufferedTime);
}
} else {
term.write(ev.data);
}
};
let handleTerminalData = function (data) {
websocket.send(
JSON.stringify({
type: "cmd",
cmd: Base64.encode(data) // encode data as base64 format
})
);
};
websocket.onmessage = handleWebSocketMessage;
if (bidirectional) {
term.on("data", handleTerminalData);
}
// send heartbeat package to avoid closing webSocket connection in some proxy environmental such as nginx.
let heartBeatTimer = setInterval(function () {
websocket.send(JSON.stringify({type: "heartbeat", data: ""}));
}, 20 * 1000);
websocket.addEventListener("close", function () {
websocket.removeEventListener("message", handleWebSocketMessage);
term.off("data", handleTerminalData);
delete term.socket;
clearInterval(heartBeatTimer);
});
};
export default {
props: {obj: {type: Object, require: true}, visible: Boolean},
name: "CompTerm",
data() {
return {
isFullScreen:false,
searchKey:"",
v: this.visible,
ws: null,
term: null,
thisV: this.visible
};
},
watch: {
visible(val) {
this.v = val;//新增result的watch,監聽變更並同步到myResult上
}
},
computed: {
wsUrl() {
let token = localStorage.getItem('token');
return `${config.wsBase}/api/ws/${this.obj.ID || 0}?cols=${this.term.cols}&rows=${this.term.rows}&_t=${token}`
}
},
methods: {
onWindowResize() {
//console.log("resize")
this.term.fit(); // it will make terminal resized.
},
doLink(ev, url) {
if (ev.type === 'click') {
window.open(url)
}
},
doClose() {
window.removeEventListener("resize", this.onWindowResize);
// term.off("resize", this.onTerminalResize);
if (this.ws) {
this.ws.close()
}
if (this.term) {
this.term.dispose()
}
this.$emit('pclose', false)//子元件對openStatus修改後向父元件傳送事件通知
},
doOpen() {
},
doOpened() {
Terminal.applyAddon(fit);
Terminal.applyAddon(webLinks);
Terminal.applyAddon(search);
this.term = new Terminal({
rows: 35,
fontSize: 18,
cursorBlink: true,
cursorStyle: 'bar',
bellStyle: "sound",
theme: defaultTheme
});
this.term.open(this.$refs.terminal);
this.term.webLinksInit(this.doLink);
// term.on("resize", this.onTerminalResize);
window.addEventListener("resize", this.onWindowResize);
this.term.fit(); // first resizing
this.ws = new WebSocket(this.wsUrl);
this.ws.onerror = () => {
this.$message.error('ws has no token, please login first');
this.$router.push({name: 'login'});
};
this.ws.onclose = () => {
this.term.setOption("cursorBlink", false);
this.$message("console.web_socket_disconnect")
};
bindTerminal(this.term, this.ws, true, -1);
bindTerminalResize(this.term, this.ws);
},
},
}
</script>
<style scoped>
</style>
5. 最終效果
6. 完整專案程式碼
1. 快速效果預覽
git clone https://github.com/dejavuzhou/felix
cd felix
go mod download
go install
echo "新增 GOBIN 到 PATH環境變數"
echo "或者"
go get github.com/dejavuzhou/felix
echo "go build && ./felix sshw"
執行程式碼felix sshw
2. Go後端程式碼:ssh2ws程式碼地址
3. Xtermjs前端程式碼:dejavuzhou/felixfe
4. 【原文地址tech.mojotv.cn】
5. 線上DEMO felix.mojotv.cn
相關文章
- 16.5k star,開源推薦,go語言寫的堡壘機Go
- JumpServer堡壘機Server
- 【堡壘機】堡壘機是啥?一線品牌有哪些?
- 雲堡壘機和信創堡壘機主要區別講解
- 01 . Go語言實現SSH遠端終端及WebSocketGoWeb
- 【堡壘機知識】三款大品牌堡壘機對比與分析
- 安裝 堡壘機 dockerDocker
- 本地Mac通過堡壘機代理實現跨堡壘機scp問題Mac
- 雲堡壘機和軟體堡壘機哪個好?區別是什麼?
- 使用docker部署JumpServer堡壘機DockerServer
- 傳統堡壘機資料可以遷移到雲堡壘機上嗎?方式有哪些?
- 開源堡壘機是什麼?開源堡壘機的優缺點是什麼?
- 玩一玩公司的堡壘機
- 為什麼說堡壘機是企業IT運維的“安全終結者”?運維
- Python Django開發的WebSSH 堡壘機PythonDjangoWeb
- 快速搭建一個go語言web後端服務腳手架GoWeb後端
- 如何通過堡壘機訪問伺服器?堡壘機無法訪問伺服器怎麼辦?伺服器
- GO 語言 Web 開發實戰一GoWeb
- 什麼是堡壘機(運維繫統)運維
- 堡壘機小知識科普-行雲管家
- 常見堡壘機小知識彙總
- 銀行使用堡壘機成功案例分享一二
- 分享一款開源堡壘機-jumpserverServer
- 運維管理---開源堡壘機介紹運維
- Epic:《堡壘之夜》iOS移動端收入僅佔7%iOS
- Go語言————1、初識GO語言Go
- Go語言開發的Web框架都有哪些?GoWeb框架
- go語言遊戲服務端開發(四)——RPC機制Go遊戲服務端RPC
- 使用 Docker 部署 Next Terminal 輕量級堡壘機Docker
- 堡壘機是什麼意思?別稱是啥?
- 堡壘機和防火牆有什麼區別?防火牆
- 開發基於Django和Websocket的堡壘機DjangoWeb
- 基於Docker搭建Jumpserver堡壘機操作實踐DockerServer
- 堡壘機、防火牆以及跳板機分別是什麼?防火牆
- go語言遊戲服務端開發(三)——服務機制Go遊戲服務端
- Go語言:crypto/ssh執行遠端命令Go
- epic堡壘之夜怎麼設定中文 堡壘之夜設定了中文沒用
- epic堡壘之夜怎麼設定中文2022 epic堡壘之夜怎麼調中文