背景
实现一个在线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相似度较高
会有一个空格,发现前端会有一个空格,让我们抓包看看
抓包截图
我们拿到websocket第一次建立连接返回的结果,终端打印是一个换行,符合预期
存在的疑问🤔️和todo
-
如何将如下字符在客户端terminal 使用echo的形式打印出来?
[6n 代表什么?
[1;34mbin[m [1;34mmedia[m [1;34msrv[m
[1;34mdev[m [1;34mmnt[m [1;34msys[m
[1;34mdocker-entrypoint.d[m [1;34mopt[m [1;34mtmp[m
[1;32mdocker-entrypoint.sh[m [1;34mproc[m [1;34musr[m
[1;34metc[m [1;34mroot[m [1;34mvar[m
[1;34mhome[m [1;34mrun[m
[1;34mlib[m [1;34msbin[m
/ # [6n
- 可以在docker terminal api的基础上,封装api,增加ssh权限注入功能,用户前端选择要登录的机器,直接启动一个唯一的docker容器,将用户的ssh私钥注入进去进行登录容器实例
- 前端增加url参数,url?container=xxx&username=xxx&timeout=xx,使用上下文控制超时时间,避免长时间长链接带来的消耗
帅比111111