一篇文章介绍web Terminal的实战并提供日志回放功能

1.背景介绍

最近项目中做了一个功能,web ssh,就是将一个terminal终端搬到web上,实现通过web页面连接指定的服务器,并进行想要的命令操作,目前像这种类型的开源产品还是比较多的,在调研了多个产品后,我选择了自己造轮子,根本原因在于安全,所有连接到服务器上的用户执行的命令,我都要审计,留存。那就涉及到我需要劫持到用户在termial中键入的命令,并判断是否是白名单命令,然后才发往服务器上进行执行,并将结果返回给终端,而对于日志回放,开源的产品asciinema提供了完备的方案。

本文涉及到技术细节,将采用开源产品goploy进行说明,这是一款功能比较强大的运维产品,需要详细了解的可以到github上查看该项目代码

2.方案设计

web ssh 的实现依托于websocket,xterm,实现了功能完备的web terminal.xterm会将用户键入的命令通过websocket发送的websocket server,websocket鉴权通过后,发送到指定的server进行ssh连接执行命令,获取结果,然后写入到websocket返回给客户端terminal.

3.代码详解

看一下核心的代码,就能了解整个过程,从前端往后端看

kotlin 复制代码
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { AttachAddon } from 'xterm-addon-attach'
import { ElMessage } from 'element-plus'
import { NamespaceKey, getNamespaceId } from '@/utils/namespace'
export class xterm {
  private serverId: number
  private element: HTMLDivElement
  private websocket!: WebSocket
  private terminal!: Terminal
  constructor(element: HTMLDivElement, serverId: number) {
    this.element = element
    this.serverId = serverId
  }
  public connect(): void {
    const isWindows =
      ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0
    this.terminal = new Terminal({
      fontSize: 14,
      cursorBlink: true,
      windowsMode: isWindows,
      theme: {
        foreground: '#ebeef5',
        background: '#1d2935',
        cursor: '#e6a23c',
        black: '#000000',
        brightBlack: '#555555',
        red: '#ef4f4f',
        brightRed: '#ef4f4f',
        green: '#67c23a',
        brightGreen: '#67c23a',
        yellow: '#e6a23c',
        brightYellow: '#e6a23c',
        blue: '#409eff',
        brightBlue: '#409eff',
        magenta: '#ef4f4f',
        brightMagenta: '#ef4f4f',
        cyan: '#17c0ae',
        brightCyan: '#17c0ae',
        white: '#bbbbbb',
        brightWhite: '#ffffff',
      },
    })
    const fitAddon = new FitAddon()
    this.terminal.open(this.element)
    this.terminal.loadAddon(fitAddon)
    fitAddon.fit()
    this.websocket = new WebSocket(
      `${location.protocol.replace('http', 'ws')}//${
        window.location.host + import.meta.env.VITE_APP_BASE_API
      }/ws/xterm?${NamespaceKey}=${getNamespaceId()}&serverId=${
        this.serverId
      }&rows=${this.terminal.rows}&cols=${this.terminal.cols}`
    )
    this.terminal.loadAddon(new AttachAddon(this.websocket))
    this.websocket.onclose = function (evt) {
      if (evt.reason !== '') {
        ElMessage.error(evt.reason)
      }
    }
  }
  public close(): void {
    this.terminal.dispose()
    this.websocket.close()
  }

  public send(message: string): void {
    this.websocket.send(message)
  }
}

代码有点长,我们简单看一下,先connect方法中,实例化了一个Terminal,这个Terminal提供了很多默认的配置,包括背影,字体等,一般我们根据自己的后台页面风格进行调整即可,同时引入FitAddon来进行窗口自适应。这是一个独立的用于xterm.js的插件,需要独立安装并引入,它允许将终端的尺寸匹配到包含元素。 安装方式:

css 复制代码
npm install --save xterm-addon-fit

在这之后,创建了一个Websocket连接,然后绑到terminal上,这里也是一个xterm.js的插件,xterm-addon-attach,具体安装:

arduino 复制代码
npm install --save xterm-addon-attach

具体的Server端的逻辑,我们稍后讲解。

说完核心的方法后,我们来看一下,页面上如何进行创建xterm对象的,如何发送命令的

csharp 复制代码
 const x = new xterm(
      terminalRefs.value[currentTerminalUUID.value] as HTMLDivElement,
      server.id
    )
    x.connect()

创建xterm对象,并连接websocket。

ini 复制代码
...
<el-row class="footer">
      <el-input
        v-model="command"
        :disabled="terminalList.length === 0"
        placeholder="Click here send to all windows"
        class="terminal-cmd"
        @keyup.enter="enterCommand"
      />
    </el-row>
    ...
function enterCommand() {
  terminalList.value.forEach((terminal) => {
    terminal.xterm?.send(command.value + '\n')
  })
  command.value = ''
}

键入的命令通过send方法发送到websocket server。 以上就是web terminal的核心功能,当然,在页面关闭时的资源关闭,回收也很重要,这里我们仅关注功能点,回收的部分,可以参考源码中的实现即可。

前端代码看完之后,我们继续服务端代码的学习,

websocket server部分:

go 复制代码
const (
	// Time allowed to read the next pong message from the peer.
	pongWait = 60 * time.Second

	// Send pings to peer with this period. Must be less than pongWait.
	pingPeriod = (pongWait * 9) / 10

	// Maximum message size allowed from peer.
	maxMessageSize = 10240
)

const (
	TypeProject = 1
	TypeMonitor = 3
)

// Client stores a client information
type Client struct {
	Conn     *websocket.Conn
	UserInfo model.User
}

// Data is message struct
type Data struct {
	Type    int
	UserIDs []int64
	Message Message
}

type Message interface {
	CanSendTo(client *Client) error
}

// Hub is a client struct
type Hub struct {
	// Registered clients.
	clients map[*Client]bool

	// Inbound messages from the clients.
	Data chan *Data

	// Register requests from the clients.
	Register chan *Client

	// Unregister requests from clients.
	Unregister chan *Client
	// ping pong ticker
	ticker chan *Client
}

func init() {
	go hub.run()
}

func (hub *Hub) Handler() []server.Route {
	return []server.Route{
		server.NewRoute("/ws/connect", http.MethodGet, hub.connect),
		server.NewRoute("/ws/xterm", http.MethodGet, hub.xterm),
		server.NewRoute("/ws/sftp", http.MethodGet, hub.sftp),
	}
}

var hub = &Hub{
	Data:       make(chan *Data),
	clients:    make(map[*Client]bool),
	Register:   make(chan *Client),
	Unregister: make(chan *Client),
	ticker:     make(chan *Client),
}

func GetHub() *Hub {
	return hub
}

func Send(d Data) {
	GetHub().Data <- &d
}

func (hub *Hub) connect(gp *server.Goploy) server.Response {
	upgrader := websocket.Upgrader{
		CheckOrigin: func(r *http.Request) bool {
			if config.Toml.CORS.Enabled {
				if config.Toml.CORS.Origins == "*" {
					return true
				} else if strings.Contains(config.Toml.CORS.Origins, r.Header.Get("origin")) {
					return true
				}
			}
			if strings.Contains(r.Header.Get("origin"), strings.Split(r.Host, ":")[0]) {
				return true
			}
			return false
		},
	}
	c, err := upgrader.Upgrade(gp.ResponseWriter, gp.Request, nil)
	if err != nil {
		log.Error(err.Error())
		return response.JSON{Code: response.Error, Message: err.Error()}
	}
	c.SetReadLimit(maxMessageSize)
	c.SetReadDeadline(time.Now().Add(pongWait))
	c.SetPongHandler(func(string) error { c.SetReadDeadline(time.Now().Add(pongWait)); return nil })
	client := &Client{
		Conn:     c,
		UserInfo: gp.UserInfo,
	}
	hub.Register <- client

	ticker := time.NewTicker(pingPeriod)
	stop := make(chan bool, 1)
	go func() {
		for {
			select {
			case <-ticker.C:
				hub.ticker <- client
			case <-stop:
				return
			}
		}
	}()
	// you must read message to trigger pong handler
	for {
		_, _, err = c.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
				log.Error(err.Error())
			}
			break
		}
	}

	defer func() {
		hub.Unregister <- client
		c.Close()
		ticker.Stop()
		stop <- true
	}()

	return response.Empty{}
}

// Run goroutine run the sync hub
func (hub *Hub) run() {
	for {
		select {
		case client := <-hub.Register:
			hub.clients[client] = true
		case client := <-hub.Unregister:
			if _, ok := hub.clients[client]; ok {
				delete(hub.clients, client)
				client.Conn.Close()
			}
		case data := <-hub.Data:
			for client := range hub.clients {
				if data.Message.CanSendTo(client) != nil {
					continue
				}
				// check userIDs list
				for _, userID := range data.UserIDs {
					if client.UserInfo.ID != userID {
						continue
					}
				}
				if err := client.Conn.WriteJSON(
					struct {
						Type    int         `json:"type"`
						Message interface{} `json:"message"`
					}{
						Type:    data.Type,
						Message: data.Message,
					}); websocket.IsCloseError(err) {
					hub.Unregister <- client
				}
			}
		case client := <-hub.ticker:
			if _, ok := hub.clients[client]; ok {
				if err := client.Conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
					hub.Unregister <- client
				}
			}
		}
	}
}

这部分代码并不复杂,先看init方法,这个是首先运行的代码,实现了一个hub.包括wesocket连接和用户信息管理,当有新的连接创建时,添加到hub里,当有连接断开时,从hub中移除连接。还有就是数据中转,将需要发送的数据,发送到指定的websocket连接。

然后就是/ws/xterm的handler。

go 复制代码
// write data to WebSocket
// the data comes from ssh server.
type xtermBufferWriter struct {
	buffer bytes.Buffer
	mu     sync.Mutex
}

// implement Write interface to write bytes from ssh server into bytes.Buffer.
func (w *xtermBufferWriter) Write(p []byte) (int, error) {
	w.mu.Lock()
	defer w.mu.Unlock()
	return w.buffer.Write(p)
}

func (hub *Hub) xterm(gp *server.Goploy) server.Response {
	upgrader := websocket.Upgrader{
		CheckOrigin: func(r *http.Request) bool {
			if config.Toml.CORS.Enabled {
				if config.Toml.CORS.Origins == "*" {
					return true
				} else if strings.Contains(config.Toml.CORS.Origins, r.Header.Get("origin")) {
					return true
				}
			}
			if strings.Contains(r.Header.Get("origin"), strings.Split(r.Host, ":")[0]) {
				return true
			}
			return false
		},
	}
	c, err := upgrader.Upgrade(gp.ResponseWriter, gp.Request, nil)
	if err != nil {
		return response.JSON{Code: response.Error, Message: err.Error()}
	}
	defer c.Close()
	c.SetReadLimit(maxMessageSize)
	c.SetReadDeadline(time.Now().Add(pongWait))
	c.SetPongHandler(func(string) error { c.SetReadDeadline(time.Now().Add(pongWait)); return nil })

	rows, err := strconv.Atoi(gp.URLQuery.Get("rows"))
	if err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}
	cols, err := strconv.Atoi(gp.URLQuery.Get("cols"))
	if err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}
	serverID, err := strconv.ParseInt(gp.URLQuery.Get("serverId"), 10, 64)
	if err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}

	srv, err := (model.Server{ID: serverID}).GetData()
	if err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}
	client, err := srv.ToSSHConfig().Dial()
	if err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}
	defer client.Close()
	// create session
	session, err := client.NewSession()
	if err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}
	defer session.Close()
	sessionStdin, err := session.StdinPipe()
	if err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}
	comboWriter := new(xtermBufferWriter)
	//ssh.stdout and stderr will write output into comboWriter
	session.Stdout = comboWriter
	session.Stderr = comboWriter
	// Request pseudo terminal
	if err := session.RequestPty("xterm", rows, cols, ssh.TerminalModes{}); err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}
	// Start remote shell
	if err := session.Shell(); err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}

	// terminal log
	tlID, err := model.TerminalLog{
		NamespaceID: gp.Namespace.ID,
		UserID:      gp.UserInfo.ID,
		ServerID:    serverID,
		RemoteAddr:  gp.Request.RemoteAddr,
		UserAgent:   gp.Request.UserAgent(),
		StartTime:   time.Now().Format("20060102150405"),
	}.AddRow()
	if err != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
		return response.Empty{}
	}

	var recorder *pkg.Recorder
	recorder, err = pkg.NewRecorder(config.GetTerminalLogPath(tlID), "xterm", rows, cols)
	if err != nil {
		log.Error(err.Error())
	} else {
		defer recorder.Close()
	}

	ticker := time.NewTicker(pingPeriod)
	defer ticker.Stop()
	flushMessageTick := time.NewTicker(time.Millisecond * time.Duration(50))
	defer flushMessageTick.Stop()
	stop := make(chan bool, 1)
	defer func() {
		stop <- true
	}()
	go func() {
		for {
			select {
			case <-ticker.C:
				if err := c.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
					c.Close()
					return
				}
			case <-flushMessageTick.C:
				if comboWriter.buffer.Len() != 0 {
					err := c.WriteMessage(websocket.BinaryMessage, comboWriter.buffer.Bytes())
					if err != nil {
						c.Close()
						return
					}
					if recorder != nil {
						if err := recorder.WriteData(comboWriter.buffer.String()); err != nil {
							log.Error(err.Error())
						}
					}
					comboWriter.buffer.Reset()
				}
			case <-stop:
				return
			}
		}
	}()

	for {
		messageType, p, err := c.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
				log.Error(err.Error())
			}
			break
		}
		if messageType != websocket.PongMessage {
			if _, err := sessionStdin.Write(p); err != nil {
				log.Error(err.Error())
				break
			}
		}
	}

	if err := (model.TerminalLog{
		ID:      tlID,
		EndTime: time.Now().Format("20060102150405"),
	}.EditRow()); err != nil {
		log.Error(err.Error())
	}

	return response.Empty{}
}

这里也并不复杂,我们捡重点的说,升级完websocket后,获取cols,和rows属性,这里是设置terminal的高度和宽度的,防止过小的窗口无法和前端的窗口匹配,无法查看完整的信息。然后就是通过serverId参数,获取server的信息,并创建ssh 连接,创建连接后,开启shell. 然后再一个goroutine中根据设置的时间的ticker来刷新输出缓存到websocket连接,缓存里就是ssh命令执行的结果。 在接下来的for循环中,读取websocket链接中用户键入的命令,并写入到ssh的连接中,来让命令在服务器上执行,执行的结果将写入到缓存中。让上面的goroutine来间隔刷新出去给websocket.

以上就是完整的web terminal的流程。大家有不懂的地方可以留言,我们一起学习。

上面还有一段代码,没有说就是

go 复制代码
	var recorder *pkg.Recorder
	recorder, err = pkg.NewRecorder(config.GetTerminalLogPath(tlID), "xterm", rows, cols)
	if err != nil {
		log.Error(err.Error())
	} else {
		defer recorder.Close()
	}
        
        ...
        if recorder != nil {
		if err := recorder.WriteData(comboWriter.buffer.String()); err != nil {
			log.Error(err.Error())
		}
	}

这段代码就是实现了如何进行日志回放的核心代码,我们来看一下如何实现的。

go 复制代码
type Env struct {
	Shell string `json:"SHELL"`
	Term  string `json:"TERM"`
}

type Header struct {
	Title     string `json:"title"`
	Version   int    `json:"version"`
	Height    int    `json:"height"`
	Width     int    `json:"width"`
	Env       Env    `json:"env"`
	Timestamp int    `json:"Timestamp"`
}

type Recorder struct {
	File      *os.File
	Timestamp int
}

func (recorder *Recorder) Close() {
	if recorder.File != nil {
		_ = recorder.File.Close()
	}
}

func (recorder *Recorder) WriteHeader(header *Header) (err error) {
	var p []byte

	if p, err = json.Marshal(header); err != nil {
		return
	}

	if _, err := recorder.File.Write(p); err != nil {
		return err
	}
	if _, err := recorder.File.Write([]byte("\n")); err != nil {
		return err
	}

	recorder.Timestamp = header.Timestamp

	return
}

func (recorder *Recorder) WriteData(data string) (err error) {
	now := int(time.Now().UnixNano())

	delta := float64(now-recorder.Timestamp*1000*1000*1000) / 1000 / 1000 / 1000

	row := make([]interface{}, 0)
	row = append(row, delta)
	row = append(row, "o")
	row = append(row, data)

	var s []byte
	if s, err = json.Marshal(row); err != nil {
		return
	}
	if _, err := recorder.File.Write(s); err != nil {
		return err
	}
	if _, err := recorder.File.Write([]byte("\n")); err != nil {
		return err
	}
	return
}

func NewRecorder(recordingPath, term string, h int, w int) (recorder *Recorder, err error) {
	recorder = &Recorder{}

	if _, err := os.Stat(path.Dir(recordingPath)); err != nil {
		if err := os.MkdirAll(path.Dir(recordingPath), os.ModePerm); err != nil {
			return recorder, err
		}
	}

	file, err := os.Create(recordingPath)
	if err != nil {
		return nil, err
	}

	recorder.File = file

	header := &Header{
		Title:     "",
		Version:   2,
		Height:    h,
		Width:     w,
		Env:       Env{Shell: "/bin/bash", Term: term},
		Timestamp: int(time.Now().Unix()),
	}

	if err := recorder.WriteHeader(header); err != nil {
		return nil, err
	}

	return recorder, nil
}

惊不惊喜,意不意外,这里的核心就是文件的写入,只是这里的文件是指定的内容格式,terminal中所有的执行过程,都会记录到该文件中。这个文件有固定的头部信息,文件格式为.cast类型文件。

这里就引入了该功能使用的工具asciinema.我们来了解一下这个工具

您可能知道SSH,屏幕或脚本命令。实际上,Asciinema的灵感来自脚本(和Scriptreplay)命令。您可能不知道的是它们都使用相同的UNIX系统功能:伪末端。

伪终端是一对伪驱动器,其中一个(从属)模拟了真实的文本终端设备,另一个终端设备(Master)提供了终端模拟器过程控制从属的手段。

这是终端仿真器与用户和外壳互动的方式:

终端模拟器过程的作用是与用户互动。将文本输入输入到主伪设备中,以供外壳使用(已连接到从属伪设备),并从主伪设备读取文本输出并将其显示给用户。

换句话说,伪末端使程序能够充当用户,显示器和外壳之间的中间人。它允许透明捕获用户输入(键盘)和终端输出(显示)。屏幕命令将其用于捕获特殊键盘快捷键,例如 ctrl-a ,并更改输出以显示窗口号/名称和其他消息。

Asciinema记录器通过利用伪终端来捕获所有输入到终端并将其保存在内存中(以及定时信息)的输出来完成其作业。捕获的输出包括原始的,不变的形式的所有文本和无形的逃生/控制序列。当录制会话完成时,它将输出(以assiicast格式)上传到asciinema.org。这就是"录制"部分。 Asciinema项目由几个互补作品构建:

当您在终端中运行Asciinema Rec时,录制开始时,捕获发出shell命令时打印到终端的所有输出。当录制完成(通过击中CTRL-D或打字出口)时,将捕获的输出上传到asciinema.org网站,并准备在网络上播放。

那如何在项目中进行使用呢,我们看一下具体代码:

css 复制代码
func (Log) GetTerminalRecord(gp *server.Goploy) server.Response {
	type ReqData struct {
		RecordID int64 `schema:"recordId" validate:"gt=0"`
	}
	var reqData ReqData
	if err := gp.Decode(&reqData); err != nil {
		return response.JSON{Code: response.IllegalParam, Message: err.Error()}
	}
	terminalLog, err := model.TerminalLog{ID: reqData.RecordID}.GetData()
	if err != nil {
		return response.JSON{Code: response.Error, Message: err.Error()}
	}
	if gp.UserInfo.SuperManager != model.SuperManager && terminalLog.NamespaceID != gp.Namespace.ID {
		return response.JSON{Code: response.Error, Message: "You have no access to enter this record"}
	}
	return response.File{Filename: config.GetTerminalLogPath(reqData.RecordID)}
}

文件读取后,返回给前端

go 复制代码
type File struct {
	Filename string
}

func (f File) Write(w http.ResponseWriter, _ *http.Request) error {
	file, err := os.Open(f.Filename)
	if err != nil {
		return err
	}

	fileStat, err := file.Stat()
	if err != nil {
		return err
	}

	w.Header().Set("Content-Disposition", "attachment; filename="+fileStat.Name())
	w.Header().Set("Content-Type", "application/x-asciicast")
	w.Header().Set("Content-Length", strconv.FormatInt(fileStat.Size(), 10))

	_, err = io.Copy(w, file)
	if err != nil {
		return err
	}
	return nil
}

前端创建AsciinemaPlayer对象进行可播放的文件查看。

javascript 复制代码
function handleRecord(data: TerminalLogData) {
  recordViewer.value = true
  const castUrl = `${location.origin}${
    import.meta.env.VITE_APP_BASE_API
  }/log/getTerminalRecord?${NamespaceKey}=${getNamespaceId()}&recordId=${
    data.id
  }`
  nextTick(() => {
    AsciinemaPlayer.create(castUrl, record.value, {
      fit: false,
      fontSize: '14px',
    })
  })
}

至此,该功能完成了核心的讲解,

参考链接

相关推荐
monkey_meng6 分钟前
【遵守孤儿规则的External trait pattern】
开发语言·后端·rust
虾球xz18 分钟前
游戏引擎学习第20天
前端·学习·游戏引擎
Estar.Lee21 分钟前
时间操作[计算时间差]免费API接口教程
android·网络·后端·网络协议·tcp/ip
我爱李星璇23 分钟前
HTML常用表格与标签
前端·html
疯狂的沙粒27 分钟前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员43 分钟前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐1 小时前
前端图像处理(一)
前端
程序猿阿伟1 小时前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒1 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪1 小时前
AJAX的基本使用
前端·javascript·ajax