web terminal的浅显实践

/ vuedevops工作流协议 / 1 条评论 / 2024浏览

背景

实现一个在线terminal,可通过websocket协议输入指令给后台api,后端实时回传指令输出结果给前端

前端技术栈:Vue+xterm(在线terminal库)

后端:Gin+melody(websocket库)

后端代码

首先实现一个websocket协议的接口

这里采用的是 gopkg.in/olahol/melody.v1

结合Gin代码如下

Router 部分

import "xxx/v1/src/ws"	
  WS := r.Group("/api")
	{
		WS.GET("/ws", ws.DemoWs)
	}

websocket部分

package ws

import (
	"github.com/gin-gonic/gin"
	meUtils "github.com/lijinghuatongxue/utils"
	"github.com/sirupsen/logrus"
	"gopkg.in/olahol/melody.v1"
	"net/http"
	"sre-checker-api/v1/config"
	"strings"
)

func DemoWs(c *gin.Context) {
	m := melody.New()
	m.Upgrader.CheckOrigin = func(r *http.Request) bool { return true }
	m.HandleMessage(func(s *melody.Session, msg []byte) {
    // 增加指令过滤,此处是简单过滤demo
		if strings.Contains(string(msg),"rm"){
			logrus.Error("危险指令! rm")
			return
		}
		logrus.Warnf("CMD -> %s ",string(msg))
    // 自己封装的一个远程执行ssh的库,主机地址填写你们自己的,注意需要放上自己的ssh私钥位置
		sshCMDOutput,_:= meUtils.RemoteCmd("主机地址","22","root",string(msg),config.AppConfig.SSH.RsaPath)
		logrus.Warn(sshCMDOutput)
		err := m.Broadcast([]byte(sshCMDOutput))
		if err != nil {
			return
		}
	})
	err := m.HandleRequest(c.Writer, c.Request)
	if err != nil {
		return
	}
}

基于 docker 的 web terminal 实现

main代码

package main

import (
	"context"
	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
	"github.com/gorilla/websocket"
	log "github.com/sirupsen/logrus"
	"io"
	"net/http"
	"time"
)

var (
	ctx context.Context
	cli *client.Client
)

func main() {
	initDockerAPI()

	http.HandleFunc("/ping", ping)
	http.HandleFunc("/ws", terminal)
	srv := &http.Server{
		Addr: "0.0.0.0:8080",
		// Good practice: enforce timeouts for servers you create!
		WriteTimeout: 15 * time.Second,
		ReadTimeout:  15 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

func initDockerAPI() {
	ctx = context.Background()
	newCli, err := client.NewClientWithOpts(client.FromEnv)
	cli = newCli
	if err != nil {
		panic(err)
	}

	cli.NegotiateAPIVersion(ctx)
}

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

func ping(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(200)
	w.Write([]byte("pong"))
}

func terminal(w http.ResponseWriter, r *http.Request) {
	// websocket握手
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Error(err)
		return
	}
	defer conn.Close()

	r.ParseForm()
	// 获取容器ID或name
	container := r.Form.Get("container")
	// 执行exec,获取到容器终端的连接
	hr, err := exec(container, r.Form.Get("workdir"))
	if err != nil {
		log.Error(err)
		return
	}
	// 关闭I/O流
	defer hr.Close()
	// 退出进程
	defer func() {
		hr.Conn.Write([]byte("exit\r"))
	}()

	go func() {
		wsWriterCopy(hr.Conn, conn)
	}()
	wsReaderCopy(conn, hr.Conn)
}

func exec(container string, workdir string) (hr types.HijackedResponse, err error) {
	// 执行/bin/sh命令
	ir, err := cli.ContainerExecCreate(ctx, container, types.ExecConfig{
		AttachStdin:  true,
		AttachStdout: true,
		AttachStderr: true,
		WorkingDir:   workdir,
		Cmd:          []string{"/bin/sh"},
		Tty:          true,
	})
	if err != nil {
		return
	}

	// 附加到上面创建的/bin/bash进程中
	hr, err = cli.ContainerExecAttach(ctx, ir.ID, types.ExecStartCheck{Detach: false, Tty: true})
	if err != nil {
		return
	}
	return
}

func wsWriterCopy(reader io.Reader, writer *websocket.Conn) {
	buf := make([]byte, 8192)
	for {
		nr, err := reader.Read(buf)
		if nr > 0 {
			err := writer.WriteMessage(websocket.BinaryMessage, buf[0:nr])
			if err != nil {
				return
			}
		}
		if err != nil {
			return
		}
	}
}

func wsReaderCopy(reader *websocket.Conn, writer io.Writer) {
	for {
		messageType, p, err := reader.ReadMessage()
		if err != nil {
			return
		}
		if messageType == websocket.TextMessage {
			writer.Write(p)
		}
	}
}

前端代码

<template>
  <div id="terminal" />
</template>

<script>
import 'xterm/css/xterm.css'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { AttachAddon } from 'xterm-addon-attach'

export default {
  name: 'Terminal',
  data() {
    return {
      containerId: this.$route.params.containerId
    }
  },
  mounted() {
    var term = new Terminal(); var fitAddon = new FitAddon()
    // 容器terminal websocket api
    // var socket = new WebSocket('ws://127.0.0.1:8080/ws?container=interesting_zhukovsky')
    // 自己封装的websocket api
    var socket = new WebSocket('ws://127.0.0.1:8080/api/ws')
    var attachAddon = new AttachAddon(socket)

    term.loadAddon(attachAddon)
    term.loadAddon(fitAddon)
    term.open(document.getElementById('terminal'))
    fitAddon.fit()
    term.focus()
    socket.onopen = () => { socket.send('\n') } // 当连接建立时向终端发送一个换行符,不这么做的话最初终端是没有内容的,输入换行符可让终端显示当前用户的工作路径

    window.onresize = function() { // 窗口尺寸变化时,终端尺寸自适应
      fitAddon.fit()
    }

    this.term = term
    this.socket = socket
  },
  beforeDestroy() {
    this.socket.close()
    this.term.destory()
  }
}
</script>
<style scoped>
#terminal{
  height: 100%;
}
</style>

前端代码省略路由和其余代码

异常情况

前端展示

存在异常的换行情况,这个实现是粗糙的,没有样式的控制

查看后端返回

通过wireshark抓包,输入关键字webscket便可捕获当前机器所有的websocket协议的流量,注意选择正确的网卡,我们本次的后端server端口是8080

下图可以看到server返回的载荷,格式为txt,后端api未做任何的格式处理

浏览器查看传输数据,绿色代表发送,红色代表接收,如果后端数据只返回干巴巴的数据,没有额外的样式,terminal输出会变得扭曲如下图

不仅需要考虑当前指令执行目录,还要考虑返回的数据换行,数据染色,返回数据增加workdir前缀

正常情况

前端

https://www.cnblogs.com/linusflow/p/7399761.html

终端的显示如果要有颜色,需要额外的格式支持,比如下面截图中的echo格式

后端

浏览器截图

可以看到每个返回的数据都有进行样式的封装,所有才会出现如下图所示,与客户端terminal相似度较高

会有一个空格,发现前端会有一个空格,让我们抓包看看

抓包截图

image-20211229151201396

我们拿到websocket第一次建立连接返回的结果,终端打印是一个换行,符合预期

存在的疑问🤔️和todo

  1. 如何将如下字符在客户端terminal 使用echo的形式打印出来?

    [6n 代表什么?

bin                   media                 srv
dev                   mnt                   sys
docker-entrypoint.d   opt                   tmp
docker-entrypoint.sh  proc                  usr
etc                   root                  var
home                  run
lib                   sbin
/ # 
  1. 可以在docker terminal api的基础上,封装api,增加ssh权限注入功能,用户前端选择要登录的机器,直接启动一个唯一的docker容器,将用户的ssh私钥注入进去进行登录容器实例
  2. 前端增加url参数,url?container=xxx&username=xxx&timeout=xx,使用上下文控制超时时间,避免长时间长链接带来的消耗
  1. ray

    帅比111111

    回复