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

men發表於2020-11-06

Crypto/ssh簡介

使用

下載
 go get "github.com/mitchellh/go-homedir"
 go get "golang.org/x/crypto/ssh"
使用密碼認證連線

連線包含了認證,可以使用password或者sshkey 兩種方式認證,下面採用密碼認證方式完成連線

Example

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)
//}

程式碼解讀

// 配置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開發者來說,上面描述有點枯燥,只需要幾下以下三點

/*
		1. WebSocket可以在瀏覽器裡使用
		2. 支援雙向通訊
		3. 使用很簡單
*/
優點

對比HTTP協議的話,概括的說就是: 支援雙向通訊,更靈活,更高效,可擴充套件性更好

/*
		1. 支援雙向通訊,實時性更強
		2. 更好的二進位制支援
		3. 較少的控制開銷,連線建立後,客戶端和服務端進行資料交換時,協議控制的資料包頭部較小,在不包含頭部的情況下,
				服務端到客戶端的包頭只有2-10位元組(取決於資料包長度), 客戶端到服務端的話,需要加上額外4位元組的掩碼,
				而HTTP每次同年高新都需要攜帶完整的頭部
		4. 支援擴充套件,ws協議定義了擴充套件, 使用者可以擴充套件協議, 或者實現自定義的子協議
*/

基於Web的Terminal終端控制檯

完成這樣一個Web Terminal的目的主要是解決幾個問題:

/*
		1. 一定程度上取代xshell,secureRT,putty等ssh終端
		2. 可以方便身份認證, 訪問控制
		3. 方便使用, 不受電腦環境的影響
*/

要實現遠端登入的功能,其資料流向大概為

/*
		瀏覽器 <-->  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


按照上面的使用流程基於程式碼解釋如何實現

升級HTTP協議為WebSocket
var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}
升級協議並獲得socket連線
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
    c.Error(err)
    return
}

conn就是socket連線通道, 接下來後臺和瀏覽器之間的通訊都將基於這個通道

後臺拿到主機資訊,建立ssh客戶端

ssh客戶端結構體

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:

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", "aes128-gcm@openssh.com", "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建立一個通道:

channel, inRequests, err := this.Client.OpenChannel("session", nil)
if err != nil {
    log.Println(err)
    return nil
}
this.channel = channel

ssh通道建立完成後, 請求一個標準輸出的終端, 並開啟使用者的預設shell:

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 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 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.這裡貼一段關鍵程式碼, 前端專案地址

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')
}

後端專案地址

相關文章