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
*/
實現流程
- 瀏覽器將主機的信息(ip, 用戶名, 密碼, 請求的終端大小等)進行加密, 傳給后台, 並通過HTTP請求與后台協商升級協議. 協議升級完成后, 后續的數據交換則遵照web Socket的協議.
- 后台將HTTP請求升級為web Socket協議, 得到一個和瀏覽器數據交換的連接通道
- 后台將數據進行解密拿到主機信息, 創建一個SSH 客戶端, 與遠程主機的SSH 服務端協商加密, 互相認證, 然后建立一個SSH Channel
- 后台和遠程主機有了通訊的信道, 然后后台將終端的大小等信息通過SSH Channel請求遠程主機創建一個 pty(偽終端), 並請求啟動當前用戶的默認 shell
- 后台通過 Socket連接通道拿到用戶輸入, 再通過SSH Channel將輸入傳給pty, pty將這些數據交給遠程主機處理后按照前面指定的終端標准輸出到SSH Channel中, 同時鍵盤輸入也會發送給SSH Channel
- 后台從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')
}