基於Websocket的簡易webshell實現

jemygraw發表於2018-11-05

我們在很多場合都看到過基於瀏覽器的 shell,你可以在裡面輸入一些和你本機相同的命令,然後從遠端伺服器獲得對應的輸出。

本篇文章就是用來講解這個基於 web 的 shell 的實現方法的。我們之所以研究這個問題,另一方面也是因為 kubernetes 的 Dashboard 裡面也包含了這個功能。 在研究 kubernetes 的 Dashboard 的時候,我們會發現那個功能是基於 WebSocket 來實現的。所以本篇也就是講解基於 WebSocket 的 shell 實現方法。

思路

我們如果仔細地思考一下,其實這個 web shell 的主要功能就是將這個命令傳送到遠端伺服器,然後遠端伺服器執行這個命令,然後把結果返回給客戶端就可以了。所以在這個客戶端和伺服器的互動場景下,有很多的方案可以選擇,比如直接使用 HTTP 協議或者使用 TCP 協議,那麼為什麼 kubernetes 在實現的使用使用 web socket協議呢?

在進行一個技術方案的選型的時候,最重要的就是深入瞭解各個方案的利弊,以及它們最適用的場景。所以我們可以對比下基於 TCP,HTTP 和 WebSocket 三種協議實現這個 webshell 的優缺點。

協議 類別 特點
WebSocket 七層(應用層) 相容HTTP的80埠和HTTPS的443埠,可以執行在HTTP或HTTPS協議上,全雙工協議,基於事件驅動的互動方式,客戶端不需要輪詢服務端的執行結果
HTTP(s) 七層(應用層) HTTP需要保持長連線來維持客戶端和伺服器之間的不斷的命令執行互動,否則頻繁的短連線效能損耗嚴重,另外客戶端需要主動輪訓服務端的執行結果
TCP 四層(傳輸層) 我就是個裸的傳輸層協議啦,HTTPS(s)和WebSocket都最終依賴我

從原理上講,大家最後都是需要依賴TCP協議來進行資料傳輸,所以如果堅持用TCP協議實現 webshell 當然是可以的,沒有任何問題。 但是協議的抽象目的就是簡化問題的解決方案以及解決舊有方案的缺點,HTTP的出現就是一種規範化的TCP協議應用,否則按照大家各自定義自己的資料格式的做法,這個網際網路還是不要搞了,沒法搞。你能腦補出每個公司每天都在互相接入對方的協議開發自己的應用麼?畫面太美,不敢想,不敢想。

那麼我們就看看 WebSokcet 的出現簡化和解決了 HTTP 協議的哪些問題就可以了。

對於互動式場景的應用,最重要的就是等待回覆的不確定性,比如你發個訊息給對方,對方什麼時候回覆是不確定的,你執行了一個遠端的命令,這個命令什麼時候執行完畢也是不確定的,在HTTP協議中,解決這種不確定性的方案是什麼呢?輪詢!你不是沒有辦法告訴我麼?我自己去問行不?可以,來問吧,週期性地詢問一下。

輪詢的做法有什麼問題呢?首先就是輪詢週期的設定,你怎麼設定這個時間呢?週期太短,白白浪費那麼多建立連線,斷開連線的動作,就像很久以前談戀愛的小夥子,每隔一分鐘去問一下傳達室的大爺,今天剛寄出的信有沒有回覆。大爺很快就口吐白沫了。但是要是每隔一天去問,看上去好像不錯,但是萬一那個信是上午到的,結果你下午才去拿,那白白痛苦等待了一個上午不是。所以這個方案不好,但是沒辦法。

剛剛說了輪詢的第一個缺點,第二個就是你總是輪詢,整個路上全是你來來往往的身影,佔用頻寬不是,浪費連線不是。

那麼 WebSocket 的出現,就可以解決這個問題了,大爺說,小夥子信發出去了不著急,等信到了大爺通知你,甚至主動把信送給你,你看好不好。

這就完美解決問題了嘛。

程式碼

BB那麼久,好歹說清楚 WebSocket 哪裡好了,簡單貼個能跑的程式碼吧。能跑就是說能展示原理,但是別直接拷貝就上線了,不好。

先上客戶端,就是模擬每次發一個命令過去,然後命令後面接上換行符,算是簡單的協議格式。

remoteshell-client.go

package main

import (
    "flag"
    "golang.org/x/net/websocket"
    "bufio"
    "fmt"
    "os"
)

func main() {
    var origin string
    var url string
    flag.StringVar(&origin, "origin", "", "websocket origin")
    flag.StringVar(&url, "url", "", "websocket remote url")
    flag.Parse()

    ws, err := websocket.Dial(url, "", origin)
    if err != nil {
        panic(err)
        return
    }
    buffer := make([]byte, 40960)
    bScanner := bufio.NewScanner(os.Stdin)
    fmt.Print("> ")
    for bScanner.Scan() {
        line := bScanner.Text()
        ws.Write([]byte(line + "\r\n"))
        num, err := ws.Read(buffer)
        if err != nil {
            ws.Close()
            return
        }
        fmt.Println(string(buffer[:num]))
        fmt.Print("> ")
    }
}

執行方法:

$ ./remoteshell-client -url 'ws://localhost:9001/remote/shell' -origin 'http://localhost:9001'

服務端就是處理這個請求並給個回覆了,因為這個連線是一直都在的,所以讀資料就是直接for迴圈去讀。我們在客戶端傳送資料的時候,給每條資料加了一個換行符,所以服務端就可以按行來讀了。

package main

import (
    "flag"
    "fmt"
    "net/http"
    "os/exec"
    "bytes"
    "strings"
    "golang.org/x/net/websocket"
    "bufio"
    "os"
)

func RemoteShell(ws *websocket.Conn) {
    bScanner := bufio.NewScanner(ws)
    currentWorkingDir, _ := os.Getwd()
    fmt.Println("current working dir", currentWorkingDir)

    for bScanner.Scan() {
        // parse command
        cmd := bScanner.Text()
        fmt.Println(cmd)
        cmdItems := strings.Split(cmd, " ")
        cmdName := cmdItems[0]

        var cmdArgs []string
        if len(cmdItems) >= 2 {
            cmdArgs = cmdItems[1:]
        }

        // execute command
        cmdOutput := bytes.NewBuffer(nil)
        cmdExec := exec.Command(cmdName, cmdArgs...)
        cmdExec.Dir = currentWorkingDir
        cmdExec.Stdout = cmdOutput
        cmdExec.Stderr = cmdOutput
        err := cmdExec.Run()
        if err != nil {
            fmt.Println(err)
            ws.Write([]byte(err.Error()))
        } else {
            ws.Write(cmdOutput.Bytes())
        }
    }
}

func main() {
    var host string
    var port int
    flag.StringVar(&host, "host", "0.0.0.0", "host to listen")
    flag.IntVar(&port, "port", 9001, "port to listen")
    flag.Parse()

    //handler
    http.Handle("/remote/shell", websocket.Handler(RemoteShell))

    //listen
    endPoint := fmt.Sprintf("%s:%d", host, port)
    err := http.ListenAndServe(endPoint, nil)
    if err != nil {
        fmt.Println(err)
        return
    }
}

執行方法:

$ ./remoteshell-server 

原文連結:https://blog.duokexuetang.com/post/go-practice-webshell-implementation-on-websocket.html

相關文章