01 . Go語言實現SSH遠端終端及WebSocket
阿新 • • 發佈:2020-11-06
#### 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