Go 语言实现 SSH 远程终端及 WebSocket
本文主要介绍了使用 golang 结合 webSocket 通信原理来实现远程 ssh 终端的方法。
使用
下载
go get "github.com/mitchellh/go-homedir"
go get "golang.org/x/crypto/ssh"
使用密码认证连接
连接包含了认证,可以使用 password 或者 sshkey 两种方式认证,下面采用密码认证方式完成连接
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')
}
在使用 xterm 时需要将其 css 文件也导入,不然会有显示问题
参考
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: Go 语言反射
下一篇: 彻底找到 Tomcat 启动速度慢的元凶
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论