1. 程式人生 > >01 . Go語言實現SSH遠端終端及WebSocket

01 . Go語言實現SSH遠端終端及WebSocket

#### Crypto/ssh簡介 #### 使用 ##### 下載 ```go go get "github.com/mitchellh/go-homedir" go get "golang.org/x/crypto/ssh" ``` ##### 使用密碼認證連線 > 連線包含了認證,可以使用password或者sshkey 兩種方式認證,下面採用密碼認證方式完成連線 `Example` ```go package main import ( "fmt" "golang.org/x/crypto/ssh" "log" "time" ) func main() { sshHost := "39.108.140.0" sshUser := "root" sshPasswrod := "youmen" sshType := "password" // password或者key //sshKeyPath := "" // ssh id_rsa.id路徑 sshPort := 22 // 建立ssh登入配置 config := &ssh.ClientConfig{ Timeout: time.Second, // ssh連線time out時間一秒鐘,如果ssh驗證錯誤會在一秒鐘返回 User: sshUser, HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 這個可以,但是不夠安全 //HostKeyCallback: hostKeyCallBackFunc(h.Host), } if sshType == "password" { config.Auth = []ssh.AuthMethod{ssh.Password(sshPasswrod)} } else { //config.Auth = []ssh.AuthMethod(publicKeyAuthFunc(sshKeyPath)) return } // dial 獲取ssh client addr := fmt.Sprintf("%s:%d",sshHost,sshPort) sshClient,err := ssh.Dial("tcp",addr,config) if err != nil { log.Fatal("建立ssh client 失敗",err) } defer sshClient.Close() // 建立ssh-session session,err := sshClient.NewSession() if err != nil { log.Fatal("建立ssh session失敗",err) } defer session.Close() // 執行遠端命令 combo,err := session.CombinedOutput("whoami; cd /; ls -al;") if err != nil { log.Fatal("遠端執行cmd失敗",err) } log.Println("命令輸出:",string(combo)) } //func publicKeyAuthFunc(kPath string) ssh.AuthMethod { // keyPath ,err := homedir.Expand(kPath) // if err != nil { // log.Fatal("find key's home dir failed",err) // } // // key,err := ioutil.ReadFile(keyPath) // if err != nil { // log.Fatal("ssh key file read failed",err) // } // // signer,err := ssh.ParsePrivateKey(key) // if err != nil { // log.Fatal("ssh key signer failed",err) // } // return ssh.PublicKeys(signer) //} ``` `程式碼解讀` ```go // 配置ssh.ClientConfig /* 建議TimeOut自定義一個比較端的時間 自定義HostKeyCallback如果像簡便就使用ssh.InsecureIgnoreHostKey會帶哦,這種方式不是很安全 publicKeyAuthFunc 如果使用key登入就需要用哪個這個函式量讀取id_rsa私鑰, 當然也可以自定義這個訪問讓他支援字串. */ // ssh.Dial建立ssh客戶端 /* 拼接字串得到ssh連結地址,同時不要忘記defer client.Close() */ // sshClient.NewSession建立會話 /* 可以自定義stdin,stdout 可以建立pty 可以SetEnv */ // 執行命令CombinnedOutput run... go run main.go 2020/11/06 00:07:31 命令輸出: root total 84 dr-xr-xr-x. 20 root root 4096 Sep 28 09:38 . dr-xr-xr-x. 20 root root 4096 Sep 28 09:38 .. -rw-r--r-- 1 root root 0 Aug 18 2017 .autorelabel lrwxrwxrwx. 1 root root 7 Aug 18 2017 bin -> usr/bin dr-xr-xr-x. 4 root root 4096 Sep 12 2017 boot drwxrwxr-x 2 rsync rsync 4096 Jul 29 23:37 data drwxr-xr-x 19 root root 2980 Jul 28 13:29 dev drwxr-xr-x. 95 root root 12288 Nov 5 23:46 etc drwxr-xr-x. 5 root root 4096 Nov 3 16:11 home lrwxrwxrwx. 1 root root 7 Aug 18 2017 lib -> usr/lib lrwxrwxrwx. 1 root root 9 Aug 18 2017 lib64 -> usr/lib64 drwx------. 2 root root 16384 Aug 18 2017 lost+found drwxr-xr-x. 2 root root 4096 Nov 5 2016 media drwxr-xr-x. 3 root root 4096 Jul 28 21:01 mnt drwxr-xr-x 4 root root 4096 Sep 28 09:38 nginx_test drwxr-xr-x. 8 root root 4096 Nov 3 16:10 opt dr-xr-xr-x 87 root root 0 Jul 28 13:26 proc dr-xr-x---. 18 root root 4096 Nov 4 00:38 root drwxr-xr-x 27 root root 860 Nov 4 21:57 run lrwxrwxrwx. 1 root root 8 Aug 18 2017 sbin -> usr/sbin drwxr-xr-x. 2 root root 4096 Nov 5 2016 srv dr-xr-xr-x 13 root root 0 Jul 28 21:26 sys drwxrwxrwt. 8 root root 4096 Nov 5 03:09 tmp drwxr-xr-x. 13 root root 4096 Aug 18 2017 usr drwxr-xr-x. 21 root root 4096 Nov 3 16:10 var ``` 以上內容摘自 https://mojotv.cn/2019/05/22/golang-ssh-session #### WebSocket簡介 > HTML5開始提供的一種瀏覽器與伺服器進行雙工通訊的網路技術,屬於應用層協議,它基於TCP傳輸協議,並複用HTTP的握手通道: > > 對大部分web開發者來說,上面描述有點枯燥,只需要幾下以下三點 ```go /* 1. WebSocket可以在瀏覽器裡使用 2. 支援雙向通訊 3. 使用很簡單 */ ``` ##### 優點 > 對比HTTP協議的話,概括的說就是: 支援雙向通訊,更靈活,更高效,可擴充套件性更好 ```go /* 1. 支援雙向通訊,實時性更強 2. 更好的二進位制支援 3. 較少的控制開銷,連線建立後,客戶端和服務端進行資料交換時,協議控制的資料包頭部較小,在不包含頭部的情況下, 服務端到客戶端的包頭只有2-10位元組(取決於資料包長度), 客戶端到服務端的話,需要加上額外4位元組的掩碼, 而HTTP每次同年高新都需要攜帶完整的頭部 4. 支援擴充套件,ws協議定義了擴充套件, 使用者可以擴充套件協議, 或者實現自定義的子協議 */ ``` #### 基於Web的Terminal終端控制檯 `完成這樣一個Web Terminal的目的主要是解決幾個問題:` ```go /* 1. 一定程度上取代xshell,secureRT,putty等ssh終端 2. 可以方便身份認證, 訪問控制 3. 方便使用, 不受電腦環境的影響 */ ``` `要實現遠端登入的功能,其資料流向大概為` ```go /* 瀏覽器 <--> WebSocket <---> SSH <---> Linux OS */ ``` ##### 實現流程 > 1. 瀏覽器將主機的資訊(ip, 使用者名稱, 密碼, 請求的終端大小等)進行加密, 傳給後臺, 並通過HTTP請求與後臺協商升級協議. 協議升級完成後, 後續的資料交換則遵照web Socket的協議. > 2. 後臺將HTTP請求升級為web Socket協議, 得到一個和瀏覽器資料交換的連線通道 > 3. 後臺將資料進行解密拿到主機資訊, 建立一個SSH 客戶端, 與遠端主機的SSH 服務端協商加密, 互相認證, 然後建立一個SSH Channel > 4. 後臺和遠端主機有了通訊的通道, 然後後臺將終端的大小等資訊通過SSH Channel請求遠端主機建立一個 pty(偽終端), 並請求啟動當前使用者的預設 shell > 5. 後臺通過 Socket連線通道拿到使用者輸入, 再通過SSH Channel將輸入傳給pty, pty將這些資料交給遠端主機處理後按照前面指定的終端標準輸出到SSH Channel中, 同時鍵盤輸入也會發送給SSH Channel > 6. 後臺從SSH Channel中拿到按照終端大小的標準輸出後又通過Socket連線將輸出返回給瀏覽器, 由此變實現了Web Terminal ![](https://img2020.cnblogs.com/blog/1871335/202011/1871335-20201106012131675-593947667.png) ![](https://img2020.cnblogs.com/blog/1871335/202011/1871335-20201106012120681-1133815447.png) `按照上面的使用流程基於程式碼解釋如何實現` ##### 升級HTTP協議為WebSocket ```go var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true }, } ``` ##### 升級協議並獲得socket連線 ```go conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { c.Error(err) return } ``` `conn就是socket連線通道, 接下來後臺和瀏覽器之間的通訊都將基於這個通道` ##### 後臺拿到主機資訊,建立ssh客戶端 `ssh客戶端結構體` ```go type SSHClient struct { Username string `json:"username"` Password string `json:"password"` IpAddress string `json:"ipaddress"` Port int `json:"port"` Session *ssh.Session Client *ssh.Client channel ssh.Channel } //建立新的ssh客戶端時, 預設使用者名稱為root, 埠為22 func NewSSHClient() SSHClient { client := SSHClient{} client.Username = "root" client.Port = 22 return client } ``` `初始化的時候我們只有主機的資訊, 而Session, client, channel都是空的, 現在先生成真正的client:` ```go func (this *SSHClient) GenerateClient() error { var ( auth []ssh.AuthMethod addr string clientConfig *ssh.ClientConfig client *ssh.Client config ssh.Config err error ) auth = make([]ssh.AuthMethod, 0) auth = append(auth, ssh.Password(this.Password)) config = ssh.Config{ Ciphers: []string{"aes128-ctr", "aes192-ctr", "aes256-ctr", "[email protected]", "arcfour256", "arcfour128", "aes128-cbc", "3des-cbc", "aes192-cbc", "aes256-cbc"}, } clientConfig = &ssh.ClientConfig{ User: this.Username, Auth: auth, Timeout: 5 * time.Second, Config: config, HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { return nil }, } addr = fmt.Sprintf("%s:%d", this.IpAddress, this.Port) if client, err = ssh.Dial("tcp", addr, clientConfig); err != nil { return err } this.Client = client return nil } ``` `ssh.Dial(“tcp”, addr, clientConfig)建立連線並返回客戶端, 如果主機資訊不對或其它問題這裡將直接失敗` ##### 通過ssh客戶端建立ssh channel,並請求一個pty偽終端,請求使用者的預設會話 `如果主機資訊驗證通過, 可以通過ssh client建立一個通道:` ```go channel, inRequests, err := this.Client.OpenChannel("session", nil) if err != nil { log.Println(err) return nil } this.channel = channel ``` `ssh通道建立完成後, 請求一個標準輸出的終端, 並開啟使用者的預設shell:` ```go ok, err := channel.SendRequest("pty-req", true, ssh.Marshal(&req)) if !ok || err != nil { log.Println(err) return nil } ok, err = channel.SendRequest("shell", true, nil) if !ok || err != nil { log.Println(err) return nil } ``` ##### 遠端主機與瀏覽器實時資料交換 > 現在為止建立了兩個通道, 一個是websocket, 一個是ssh channel, 後臺將起兩個主要的協程, 一個不停的從websocket通道里讀取使用者的輸入, 並通過ssh channel傳給遠端主機: ```go //這裡第一個協程獲取使用者的輸入 go func() { for { // p為使用者輸入 _, p, err := ws.ReadMessage() if err != nil { return } _, err = this.channel.Write(p) if err != nil { return } } }() ``` > 第二個主協程將遠端主機的資料傳遞給瀏覽器, 在這個協程裡還將起一個協程, 不斷獲取ssh channel裡的資料並傳給後臺內部建立的一個通道, 主協程則有一個死迴圈, 每隔一段時間從內部通道里讀取資料, 並將其通過websocket傳給瀏覽器, 所以資料傳輸並不是真正實時的,而是有一個間隔在, 我寫的預設為100微秒, 這樣基本感受不到延遲, 而且減少了消耗, 有時瀏覽器輸入一個命令獲取大量資料時, 會感覺資料出現會一頓一頓的便是因為設定了一個間隔: ```go //第二個協程將遠端主機的返回結果返回給使用者 go func() { br := bufio.NewReader(this.channel) buf := []byte{} t := time.NewTimer(time.Microsecond * 100) defer t.Stop() // 構建一個通道, 一端將資料遠端主機的資料寫入, 一段讀取資料寫入ws r := make(chan rune) // 另起一個協程, 一個死迴圈不斷的讀取ssh channel的資料, 並傳給r通道直到連線斷開 go func() { defer this.Client.Close() defer this.Session.Close() for { x, size, err := br.ReadRune() if err != nil { log.Println(err) ws.WriteMessage(1, []byte("\033[31m已經關閉連線!\033[0m")) ws.Close() return } if size > 0 { r <- x } } }() // 主迴圈 for { select { // 每隔100微秒, 只要buf的長度不為0就將資料寫入ws, 並重置時間和buf case <-t.C: if len(buf) != 0 { err := ws.WriteMessage(websocket.TextMessage, buf) buf = []byte{} if err != nil { log.Println(err) return } } t.Reset(time.Microsecond * 100) // 前面已經將ssh channel裡讀取的資料寫入建立的通道r, 這裡讀取資料, 不斷增加buf的長度, 在設定的 100 microsecond後由上面判定長度是否返送資料 case d := <-r: if d != utf8.RuneError { p := make([]byte, utf8.RuneLen(d)) utf8.EncodeRune(p, d) buf = append(buf, p...) } else { buf = append(buf, []byte("@")...) } } } }() ``` `web terminal的後臺建好了` ##### 前端 > 前端我選擇用了vue框架(其實這麼小的專案完全不用vue), 終端工具用的是xterm, vscode內建的終端也是採用的xterm.這裡貼一段關鍵程式碼, **[前端專案地址](https://github.com/chengjoey/web-terminal-client)** ```go mounted () { var containerWidth = window.screen.height; var containerHeight = window.screen.width; var cols = Math.floor((containerWidth - 30) / 9); var rows = Math.floor(window.innerHeight/17) - 2; if (this.username === undefined){ var url = (location.protocol === "http:" ? "ws" : "wss") + "://" + location.hostname + ":5001" + "/ws"+ "?" + "msg=" + this.msg + "&rows=" + rows + "&cols=" + cols; }else{ var url = (location.protocol === "http:" ? "ws" : "wss") + "://" + location.hostname + ":5001" + "/ws"+ "?" + "msg=" + this.msg + "&rows=" + rows + "&cols=" + cols + "&username=" + this.username + "&password=" + this.password; } let terminalContainer = document.getElementById('terminal') this.term = new Terminal() this.term.open(terminalContainer) // open websocket this.terminalSocket = new WebSocket(url) this.terminalSocket.onopen = this.runRealTerminal this.terminalSocket.onclose = this.closeRealTerminal this.terminalSocket.onerror = this.errorRealTerminal this.term.attach(this.terminalSocket) this.term._initialized = true console.log('mounted is going on') } ``` **[後端專案地址](https://github.com/chengjoey/web-term