Go 语言实现 SSH 远程终端及 WebSocket

发布于 2024-07-20 08:53:29 字数 12498 浏览 12 评论 0

本文主要介绍了使用 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
*/

实现流程

    1. 浏览器将主机的信息(ip, 用户名, 密码, 请求的终端大小等) 进行加密, 传给后台, 并通过 HTTP 请求与后台协商升级协议. 协议升级完成后, 后续的数据交换则遵照 web Socket 的协议.
    1. 后台将 HTTP 请求升级为 web Socket 协议, 得到一个和浏览器数据交换的连接通道
    1. 后台将数据进行解密拿到主机信息, 创建一个 SSH 客户端, 与远程主机的 SSH 服务端协商加密, 互相认证, 然后建立一个 SSH Channel
    1. 后台和远程主机有了通讯的信道, 然后后台将终端的大小等信息通过 SSH Channel 请求远程主机创建一个 pty(伪终端), 并请求启动当前用户的默认 shell
    1. 后台通过 Socket 连接通道拿到用户输入, 再通过 SSH Channel 将输入传给 pty, pty 将这些数据交给远程主机处理后按照前面指定的终端标准输出到 SSH Channel 中, 同时键盘输入也会发送给 SSH Channel
    1. 后台从 SSH Channel 中拿到按照终端大小的标准输出后又通过 Socket 连接将输出返回给浏览器, 由此变实现了 Web Terminal

image

image

按照上面的使用流程基于代码解释如何实现

升级 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 文件也导入,不然会有显示问题

参考

  1. gin-web-machine-ws

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

心不设防

暂无简介

0 文章
0 评论
23 人气
更多

推荐作者

我们的影子

文章 0 评论 0

素年丶

文章 0 评论 0

南笙

文章 0 评论 0

18215568913

文章 0 评论 0

qq_xk7Ean

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文