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


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

后端項目地址


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM