[Go WebSocket] 基于Go WebSocket手写一个简易版的远程web shell

大家好,我是公众号「线下聚会游戏」作者,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。其中的核心技术就是WebSocket,我会分享如何用Go实现WebSocket服务,文章写在专栏《Go WebSocket》里,关注专栏跟我一起学习吧!

背景

在专栏《Go WebSocket》里,有一些前置文章:

第一篇文章:《为什么我选用Go重构Python版本的WebSocket服务?》,介绍了我的目标。

第二篇文章:《你的第一个Go WebSocket服务: echo server》,介绍了一下怎么写一个WebSocket server。

第三篇文章:《你的第二个Go WebSocket服务: 聊天室》,介绍了如何实现一个单房间的聊天室。

第四篇文章:《你的第三个Go WebSocket服务: 多房间的聊天室(上:思考篇)》,介绍了实现一个多房间的聊天室的思路。

第五篇文章:《你的第三个Go WebSocket服务: 多房间的聊天室(下:实践篇)》,介绍了实现一个多房间的聊天室的代码。

第六篇文章:《你的第三个Go WebSocket服务: 多房间的聊天室(自动清理无人房间)》,介绍了如何清理无人的房间,避免内存无限增长的问题。

第七篇文章:《你的第三个Go WebSocket服务: 多房间的聊天室(黑天鹅事件)》,介绍了如何避免并发导致的资源竞争的问题,是通过悲观锁解决的。

温馨提示:阅读本文不需要阅读前面的文章。但最好先读完前三篇。

本文介绍了一个gorilla/websocket官方提供的简易版的web shell案例。

代码

见这里: github.com/gorilla/web…

体验

go run main.go sh

然后浏览器打开 127.0.0.1:8080,就可以输入lspwd等命令体验了。

image.png

但是这是个简易的 Web Shell,所以不支持vim这种命令。只能处理简单的stdin和stdout。

另外,它有一个参数,刚才我们传入的是sh,你也可以传入其它可执行命令,例如echo,会开启echo的交互命令。执行go run main.go echo后,在浏览器内,你输入什么,它返回什么。

从main函数开始

阅读一段go代码,应该从外到里,一层一层拨开她的衣。

var (
   addr    = flag.String("addr", "127.0.0.1:8080", "http service address")
   cmdPath string
)
func main() {
   flag.Parse()
   if len(flag.Args()) < 1 {
      log.Fatal("must specify at least one argument")
   }
   var err error
   cmdPath, err = exec.LookPath(flag.Args()[0])
   if err != nil {
      log.Fatal(err)
   }
   http.HandleFunc("/", serveHome)
   http.HandleFunc("/ws", serveWs)
   log.Fatal(http.ListenAndServe(*addr, nil))
}

flag.Parse()是在处理参数,这里要求必须有1个参数,后续会执行这个参数对应的命令,就可以在Web中交互了。

关于exec.LookPath(flag.Args()[0]):这是在环境变量中寻找PATH,返回一个字符串(可能是绝对路径或相对路径)。

随后启动了http服务(用serveHome处理),和websocket服务(用serveWs处理)。前者是展示html,后者处理websocket。

阅读serveWs

建立ws连接

ws, err := upgrader.Upgrade(w, r, nil)
defer ws.Close()

上面是建立ws连接,以前聊过,不多说了。

创建用于标准输出的Pipe

outr, outw, err := os.Pipe()
if err != nil {
   internalError(ws, "stdout:", err)
   return
}
defer outr.Close()
defer outw.Close()

Pipe returns a connected pair of Files; reads from r return bytes written to w. It returns the files and an error, if any.

上面是新建管道os.Pipe,之后用于连接标准输出。

创建用于标准输入的Pipe

inr, inw, err := os.Pipe()
if err != nil {
   internalError(ws, "stdin:", err)
   return
}
defer inr.Close()
defer inw.Close()

上面是新建管道os.Pipe,之后用于连接标准输出。

让操作系统执行PATH对应的命令(启动了新的进程)

刚刚我们LookPath找到了具体要执行的命令,现在让操作系统执行它,通过os.StartProcess

proc, err := os.StartProcess(cmdPath, flag.Args(), &os.ProcAttr{
   Files: []*os.File{inr, outw, outw},
})
if err != nil {
   internalError(ws, "start:", err)
   return
}

inr.Close()
outw.Close()

StartProcess会启动一个进程,flag.Args()作为它的参数。

为了在Go这个进程中跟另一个进程交互,需要通过Pipe连接,就是我们刚才定义的2个。程序都有标准输入、标准输出、异常输出,所以定义了os.ProcAttr,这里我们把异常输出也输出到了标准输出了。

启动其它goroutine,处理输入输出

stdoutDone := make(chan struct{})
go pumpStdout(ws, outr, stdoutDone)
go ping(ws, stdoutDone)

pumpStdin(ws, inw)

pumpStdout处理进程的输出;pumpStdin处理进程的输入。ping只是为了跟客户端保持持久的连接。

stdoutDone是进程结束的标志,结束后,ws连接也要断开(毕竟连着也没法交互,没意义了)。

先阅读简单的ping

func ping(ws *websocket.Conn, done chan struct{}) {
   ticker := time.NewTicker(pingPeriod)
   defer ticker.Stop()
   for {
      select {
      case <-ticker.C:
         if err := ws.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(writeWait)); err != nil {
            log.Println("ping:", err)
         }
      case <-done:
         return
      }
   }
}

是个死循环,每隔一段时间(pingPeriod),都会主动个PingMessage,保持连接。浏览器收到后会自动回复Pong消息。通过这种方式,双方都知道彼此还连着。

当然,如果done了,进程结束,就可以停止ping了。相反也是一样,ws断开连接时,进程也可以结束了。

阅读pumpStdin

注意,pumpStdin不是在serveWs用go开启的goroutine。所以到这里时,其实serveWs就阻塞在pumpStdin里的死循环了。

func pumpStdin(ws *websocket.Conn, w io.Writer) {
   defer ws.Close()
   ws.SetReadLimit(maxMessageSize)
   ws.SetReadDeadline(time.Now().Add(pongWait))
   ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(pongWait)); return nil })
   for {
      _, message, err := ws.ReadMessage()
      if err != nil {
         break
      }
      message = append(message, '\n')
      if _, err := w.Write(message); err != nil {
         break
      }
   }
}

主要就是读取ws消息,然后把消息写入w(即inw这个Pipe),之后,上面说的新启动的进程会收到这个消息。

阅读pumpStdout

func pumpStdout(ws *websocket.Conn, r io.Reader, done chan struct{}) {
   s := bufio.NewScanner(r)
   for s.Scan() {
      ws.SetWriteDeadline(time.Now().Add(writeWait))
      if err := ws.WriteMessage(websocket.TextMessage, s.Bytes()); err != nil {
         ws.Close()
         break
      }
   }
   if s.Err() != nil {
      log.Println("scan:", s.Err())
   }
   close(done)

   ws.SetWriteDeadline(time.Now().Add(writeWait))
   ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
   time.Sleep(closeGracePeriod)
   ws.Close()
}

新启动的进程有标准输出或异常输出时,会发送到Pipe,我们代码中通过outr可获取到输出,即本函数的参数r

这是个死循环,不断读取s.Scan()进程输出,然后通过ws发给客户端。

直到ws断开,就结束了进程close(donw)

写在最后

我是HullQin,独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费无广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这2个专栏里分享:《教你做小游戏》《极致用户体验》

本文正在参加技术专题18期-聊聊Go语言框架

猜你喜欢

转载自juejin.im/post/7125453657180471327