泰裤辣!服务器还可以这样访问?!

2,413 阅读5分钟

通常在访问远程服务器时一般是通过ssh协议来远程登录,本地使用的客户端如如mac的iterm或windows的xshell,这样安装起来也比较麻烦,有没有想过通过浏览器就能实现操作服务器呢?本文介绍下基于websocket和linux pty实现的web终端及实现,下面会简称为webshell,首先给大家展示下实际效果:在启动项目go run main.go后,在浏览器访问本地(或实际IP)的8080端口后访问

屏幕录制2023-05-02+09.48.19.gif

websocket协议

websocket协议是基于TCP长链接的双向通信协议,主要用于浏览器应用,实际场景包括股票交易信息实时更新、多人聊天室、弹幕、webshell等。如果基于HTTP协议实现如上的客户端-服务端双向通信,需要客户端发起多个HTTP请求去轮询结果,导致了服务端的高连接和负载以及大量重复Header传输,网络传输效率低。websocket基于一条TCP链接,客户端和服务端能独立推送和接收数据。

websocket协议和HTTP协议都是应用层协议,彼此独立,唯一的关联是websocket的握手协商是基于HTTP的upgrade机制,如浏览器想要和服务端建立websocket连接,需要发送HTTP请求如下:

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket			
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: binary, text
Sec-WebSocket-Version: 13
  1. Upgrade: websocketConnection: Upgrade标识客户端发起了ws链接,基于HTTP实现
  2. Sec-WebSocket-Protocol: binary, text表示本次ws链接客户端支持的应用层类型,binary、text分别表示是二进制和文本数据类型,表示如果握手成功,接下来客户端会发送这两种类型的消息。
  3. Sec-WebSocket-Key:一个验证码机制,服务端需要根据这个值计算Sec-WebSocket-Accept并放到response的header中

如果服务端同意本次ws连接请求,需要发送HTTP响应如下:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

此时就已经建立了好ws连接了,客户端和服务端可以双向通信了。下面是关于一个ws应用的抓包数据,从图中可以看到ws连接通过一次HTTP请求即可建立

pty虚拟终端

pty是pseudoterminal(虚拟终端/伪终端)的简称,一般我们远程登录ssh看到的计算机信息,都是基于pty实现的。pty是一对提供双向通信的虚拟设备,一端叫pts(pseudoterminal slave),一端叫ptm(pseudoterminal master),pts端实际上就是一个经典的Linux终端。

在Linux中,有一个特殊的设备/dev/ptmx,就是x个ptm的意思,只要你打开/dev/ptmx会得到文件句柄fd,同时Linux系统会在/dev/pts/目录下创建一个设备文件,比如叫/dev/pts/1000,这个文件和fd就相当于slave和master的关系,向fd写入数据,会自动传递到/dev/pts/1000这个设备中。

假如我们启动过一个shell进程,如/bin/bash,然后将这个进程的stdin、stdout、stderr都设置为pts/dev/pts/1000的stdin、stdout、stderr,这时如果我们向/dev/ptmxfd进行输入指令,其实就相当于在刚刚的/bin/bash中执行,输出结果也会通过fd展示,这样,我们就得到了一个类似ssh远程登陆的虚拟终端了

代码实现

webshell的实现原理其实就是将网络层面的websocket和机器层面的pty连接,一端是浏览器的输出输出(这点我们可以通过html的操作实现),一端就是/bin/bash进程了,这样我们就能在浏览器上实现和iterm一样的功能了。

下面我们会从前后端两部分讲解如何实现一个本地机器的webshell系统(主要支持MAC和linux系统)

在后端的实现上,webshell的核心就两点,websocket和pty,可以基于下面两个开源库实现:

后端代码 - main.go

package main

import (
	"bytes"
	"io"
	"log"
	"net/http"
	"net/url"
	"os/exec"
	"reflect"
	"strconv"

	"github.com/creack/pty"
	"github.com/gorilla/websocket"
	"github.com/pkg/errors"
)

var (
	cmdPath string = "/bin/bash"
)

func main() {
    // 处理websocket
	http.HandleFunc("/ws", serveWs)
    // 前端静态文件
	http.Handle("/", http.FileServer(http.Dir("./")))
	log.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
}

func serveWs(w http.ResponseWriter, r *http.Request) {
    // 启动本地的pty
	c := exec.Command(cmdPath)
	ptmx, err := pty.Start(c)
	if err != nil {
		return
	}
	defer ptmx.Close()

	conn, err := NewConn(w, r, func(msg []byte) {
        // 浏览器resize事件回调
		u, err := url.Parse(string(msg))
		if err != nil {
			return
		}
		var row, col int64
		for k, v := range u.Query() {
			switch k {
			case "row":
				row, _ = strconv.ParseInt(v[0], 10, 63)
			case "col":
				col, _ = strconv.ParseInt(v[0], 10, 63)
			}
		}
		if row == 0 || col == 0 {
			return
		}
		err = pty.Setsize(ptmx, &pty.Winsize{
			Rows: uint16(row),
			Cols: uint16(col),
		})
		if err != nil {
			return
		}
	})
	if err != nil {
		return
	}
	defer conn.Close()

    // stdin、stdout双向绑定
	go func() {
		_, _ = io.Copy(ptmx, conn.(io.Reader))
	}()
	_, _ = io.Copy(conn.(io.Writer), ptmx)
}

// websocket的协议升级以及请求处理
type conn struct {
	wsConn *websocket.Conn
	// read buffer between websocket and application
	rBuf         *bytes.Reader
	eventHandler func(msg []byte)
}

func NewConn(w http.ResponseWriter, r *http.Request, eventHandler func(msg []byte)) (io.ReadWriteCloser, error) {
	upgrader := websocket.Upgrader{}
	ws, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		return nil, errors.Wrapf(err, "upgrade websocket failed")
	}
	return &conn{
		wsConn:       ws,
		eventHandler: eventHandler,
	}, nil
}

func (c *conn) Read(p []byte) (n int, err error) {
	for c.rBuf == nil || c.rBuf.Len() == 0 {
		mt, mp, err := c.wsConn.ReadMessage()
		if err != nil {
			return 0, err
		}
		if len(mp) == 0 {
			continue
		}

		switch mt {
		// only read text/binary msg
		case websocket.TextMessage, websocket.BinaryMessage:
			if len(p) > 9 && reflect.DeepEqual(mp[:9], []byte("ws://ctrl")) {
				c.eventHandler(mp)
				continue
			}
			c.rBuf = bytes.NewReader(mp)
		default:
			continue
		}
	}
	n, err = c.rBuf.Read(p)
	if err == io.EOF {
		err = nil
	}
	return n, err
}

func (c *conn) Write(p []byte) (n int, err error) {
	// choose binary msg type in here
	err = c.wsConn.WriteMessage(websocket.TextMessage, p)
	if err != nil {
		return 0, err
	}
	return len(p), nil
}

func (c *conn) Close() error {
	if c.wsConn != nil {
		return c.wsConn.Close()
	}
	return nil
}

前端代码 - index.html

前端采用了xterm、xterm-addon-attach、xterm-addon-fit NPM包,需要在index.html同级目录下执行npm install xterm && npm install xterm-addon-attach && npm install xterm-addon-fit

  • xterm:开源终端实现方案
  • xterm-addon-attach:xterm websocket支持插件
  • xterm-addon-fit:xterm屏幕适应插件

index.html的16行需要切换为后端IP

<!doctype html>
<html>
  <head>
    <link rel="stylesheet" href="./node_modules/xterm/css/xterm.css" />
    <script src="./node_modules/xterm/lib/xterm.js"></script>
    <script src="./node_modules/xterm-addon-attach/lib/xterm-addon-attach.js"></script>
    <script src="./node_modules/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
  </head>

  <body>
    <div id="terminal" style="height: 100vh;"></div>
    <script type="module">
      let openned = false;

      // 这里切换为后端真实IP
      const ws = new WebSocket('ws://{{后端IP}}:8080/ws');
      const attachAddon = new AttachAddon.AttachAddon(ws);
      const fitAddon = new FitAddon.FitAddon();
      const webgl = new WebglAddon.WebglAddon();
      var term = new Terminal();
      term.loadAddon(attachAddon);
      term.loadAddon(fitAddon);

      term.open(document.getElementById('terminal'));
      fitAddon.fit();
      term.loadAddon(webgl);
      webgl.onContextLoss = function () {
        addon.dispose();
      };

      function debounce(fn, wait) {
        let timeout = null;
        return function () {
          if (timeout !== null) {
            clearTimeout(timeout);
          }
          timeout = setTimeout(fn, wait);
        }
      }

      ws.onopen = function() {
        fitAddon.fit();
        openned = true;
        ws.send("ws://ctrl?col=" + String(term.cols) + "&row=" + String(term.rows))
        term.focus();
      };

      window.addEventListener('resize', debounce(function (event) {
        fitAddon.fit();
        if (openned) {
          ws.send("ws://ctrl?col=" + String(term.cols) + "&row=" + String(term.rows))
                  }
                  }, 1500), false);
                  </script>
  </body>
</html>

实际效果:用tree打印出项目的目录结构

总结

以上就是webshell的全部代码了,大家快去试试吧