天天看點

[Go WebSocket] 基于Go WebSocket手寫一個簡易版的遠端web shell背景代碼從main函數開始閱讀serveWs先閱讀簡單的ping閱讀pumpStdin閱讀pumpStdout寫在最後

大家好,我是公衆号「線下聚會遊戲」作者HullQin,開發了《聯機桌遊合集》,是個網頁,可以很友善的跟朋友聯機玩鬥地主、五子棋等遊戲。

背景

在專欄《Go WebSocket》裡,有一些前置文章:

第一篇文章:《為什麼我選用Go重構Python版本的WebSocket服務?》,介紹了我的目标。

第二篇文章:《你的第一個Go WebSocket服務: echo server》,介紹了一下怎麼寫一個WebSocket server。

第三篇文章:《單房間的聊天室》,介紹了如何實作一個單房間的聊天室。

第四篇文章:《多房間的聊天室(一)思考篇》,介紹了實作一個多房間的聊天室的思路。

第五篇文章:《多房間的聊天室(二)代碼實作》,介紹了實作一個多房間的聊天室的代碼。

第六篇文章:《多房間的聊天室(三)自動清理無人房間》,介紹了如何清理無人的房間,避免記憶體無限增長的問題。

第七篇文章:《多房間的聊天室(三)自動清理無人房間》,介紹了如何避免并發導緻的資源競争的問題,是通過悲觀鎖解決的。

溫馨提示:閱讀本文不需要閱讀前面的文章。但最好先讀完前三篇。

本文介紹了一個gorilla/websocket官方提供的簡易版的web shell案例。

代碼

見這裡: https://github.com/gorilla/websocket/blob/master/examples/command/main.go

體驗

go run main.go sh
           

然後浏覽器打開 127.0.0.1:8080,就可以輸入

ls

pwd

等指令體驗了。

[Go WebSocket] 基于Go WebSocket手寫一個簡易版的遠端web shell背景代碼從main函數開始閱讀serveWs先閱讀簡單的ping閱讀pumpStdin閱讀pumpStdout寫在最後

但是這是個簡易的 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

寫在最後