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)
ssh2ws/internal/ws_ssh.go
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会执行命令,包把执行结果输出到comboWriter -
if _, 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 数据库结构必须前后端协商一致
vuejs+xterm.js+websocket示例代码
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