zhouhaibing089/blog

Protocol Upgrade from HTTP to WebSocket, and Proxy SSH

Opened this issue · 0 comments

Check this out: https://github.com/zhouhaibing089/ssh-over-websocket

实在不知道起个什么名, 就罗列了一堆看似牛逼的术语. 对于此文呢, 我也实在无法理出章法, 故但求随意, 倘若读者能稍微学到一点东西, 或是因此而有个什么创新的想法, 我便觉得此文有点意义, 心中便有些许满足.

简单来说呢, 通过阅读此文, 你可能会收获:

  1. 知晓如何写一个http server, 它能做protocol upgrade.
  2. 了解如何用go写一个ssh client.
  3. 可能最重要的是: 熟悉 kubectl exec 的实现原理.

Protocol Upgrade

Protocol upgrade是HTTP/1.1协议中的一部分(在HTTP 2中它被显式禁止了). 该机制允许服务端指引客户端做协议切换, 并且主要是用于切换至WebSocket.

WebSocket这个协议提供了一个在TCP连接上做全双工的通信方式, 此处全双工是关键, 它意味着客户端和服务端能够互相发送消息. 在HTTP的通信模式中, 服务端基本完全处于一个被动模式中: 等待客户端请求, 然后发送回应. 这种模式对于很多应用就显得很不友好, 比如说聊天软件, 服务端就没办法主动推送消息至客户端.

WebSocket就可以解决此问题, 而Protocol Upgrade就是从HTTP通向WebSocket的大门. 我们来看一个简单的服务端实现:

import (
	"net/http"
	"time"

	"github.com/gorilla/websocket"
)

var upgrader websocket.Upgrader = websocket.Upgrader{}

func handler(w http.ResponseWriter, r *http.Request) {
	...

	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		...
	}

	// read from the connection
	messageType, reader, err := conn.NextReader()

	// write message to connection
	conn.WriteMessage(websocket.BinaryMessage, []byte("foo"))
}

这里使用了github.com/gorilla/websocket这个库, 所以欲知详情, 可移步这里. 值得指出的是: websocket提供了多种消息类型, 上面的例子中, conn.NextReader()返回的第一个参数指明了消息类型, 而在conn.WriteMessage(..中, 我们也显示指定了消息类型为websocket.BinaryMessage. 所有的消息类型包括:

  • TextMessage: 普通UTF-8编码的文本.
  • BinaryMessage: 二进制消息.
  • CloseMessage: 控制消息, 关闭连接.
  • PingMessage: 控制消息.
  • PongMessage: 控制消息.

在我们移步下一个主题前, 我们再简单描述一下如何写一个客户端:

import (
	"github.com/gorilla/websocket"
)

dialer := websocket.Dialer{}
conn, resp, err := dialer.Dial("ws://<host>:<host>/<path>", httpHeaders)

其中ws://指明我们要访问的websocket协议, 如果服务端启用了https的话, 我们需要使用wss://. 可以看到, 我们也可以指定额外的HTTP头部信息, 比如如果服务端需要做认证的话, 我们就可以在此指定认证消息了.

拿到conn之后, 客户端的调用方式和服务端的调用方式无异, 皆为NextReaderWriteMessage.

SSH Client

得益于golang.org/x/crypto/ssh这个库, 我们可以非常轻松地构建一个ssh客户端. 简单来说, 创建一个ssh会话包括以下几个步骤:

  1. 准备认证信息: 常见的又用户名/密码或者私钥.
  2. 创建连接.
  3. 创建会话.
  4. 请求一个虚拟终端.

认证信息

我们以私钥为例:

import (
	"io/ioutil"
	"log"

	"golang.org/x/crypto/ssh"
)
// initialize the ssh client config
keyBytes, err := ioutil.ReadFile("path/to/ssh/key")
if err != nil {
	log.Fatalf("failed to read key: %s", err)
}
signer, err := ssh.ParsePrivateKey(keyBytes)
if err != nil {
	log.Fatalf("failed to parse key: %s", err)
}
sshConfig = &ssh.ClientConfig{
	User: "<user>",
	Auth: []ssh.AuthMethod{
		ssh.PublicKeys(signer),
	},
}
// skip host key check
sshConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey()

在该处例子中, 我们先读取本地的私钥, 解析成一个signer, 然后用该signer创建client配置. 在ssh库中也提供了一其他的认证方式, 比如用户名密码这种方式:

import (
	"golang.org/x/crypto/ssh"
)

sshConfig = &ssh.ClientConfig{
	User: "<user>",
	Auth: []ssh.AuthMethod{
		ssh.Password("<password>"),
	},
}

创建连接以及会话

这个非常直接:

import (
	"log"

	"golang.org/x/crypto/ssh"
)

// ...

client, err := ssh.Dial("tcp", host+":22", sshConfig)
if err != nil {
	log.Printf("failed to dial %s: %s\n", host, err)
	return
}
defer client.Close()

session, err := client.NewSession()
if err != nil {
	log.Printf("failed to new session: %s\n", err)
	return
}
defer session.Close()

ssh.Dial处理了建立连接以及握手协议. ssh.NewSession创建一个新的通道, 该通道主要用来远程执行程序. 如果我们只是非常简单的调用一条命令, 那这个时候已经开始用session来执行了:

// run command directly
err := session.Run("ls")
// run command and get output from stdout
output, err := session.Output("ls")
//  run command and get output from stdout and stderr
output, err := session.CombinedOutput("ls")
// just initiate the call, not waiting for the execution result
err := session.Start("ls")

但是如果我们要模拟一个完整的交互式会话 我们就需要使用session.Shell方法了.

请求虚拟终端

session.Shell会启动一个登录shell, 在这个shell中, 我们可以交互式地执行命令, 不过通常来说我们都会需要在虚拟终端中来运行shell.

import (
	"log"

	"golang.org/x/crypto/ssh"
)

modes := ssh.TerminalModes{
	ssh.ECHO:          1,     // enable echoing
	ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
	ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
}
if err := session.RequestPty("linux", <height>, <width>, modes); err != nil {
	log.Printf("failed to request for pseudo terminal: %s\n", err)
	return
}
if err := session.Shell(); err != nil {
	log.Printf("failed to start shell: %s\n", err)
	return
}

这段代码先是设置了一些关键的终端参数. ssh.ECHO表示要输出接收到的输入. 然后session.RequestPty中, 我们指定了TERM环境变量为linux, 也有一些其他的值可选, 比如xterm, 但是貌似linux可以和vim协作得最好. <height><width>指定了我们期望的终端大小.

开启终端之后, 我们还需要获取该会话的标准输入输出才能与之交互:

stdin, err := session.StdinPipe()
if err != nil {
	log.Printf("failed to pipe stdin: %s\n", err)
	return
}
stdout, err := session.StdoutPipe()
if err != nil {
	log.Printf("failed to pipe stdout: %s\n", err)
	return
}
stderr, err := session.StderrPipe()
if err != nil {
	log.Printf("failed to pipe stderr: %s", err)
}

Proxy SSH

至此, 我们已经知道了怎么做协议升级, 也知道怎么创建一个SSH会话, 那我们可不可以把这两者结合起来呢? 比如说我们是不是可以在websocket上代理ssh连接呢?

client (stdin) ---websocket--> server (stdin) ---ssh--> server (stdin)
server (stdout) ---ssh--> server (stdout) ---websocket--> client (stdout)
server (stderr) ---ssh--> server (stderr) ---websocket--> client (stderr)

也就是说, 我们可以在服务端建立好ssh连接, 再把客户端的标准输入全部通过websocket发送给服务端, 服务端再pipe到ssh连接上, 同理, 我们把ssh的标准输出再通过websocket全部发给客户端. 最后从客户端的角度来说, 就相当于可以完全控制这个ssh会话了.

客户端的标准输入

一个很简单的拷贝标准输入实现:

for {
	buffer := make([]byte, 32*1024)
	if n, err := os.Stdin.Read(buffer); err != nil {
		return fmt.Errorf("failed to read stdin: %s", err)
	}
	if err := conn.WriteMessage(websocket.BinaryMessage, b); err != nil {
		return fmt.Errorf("failed to write message: %s", err)
	}
}

同理, 其他输出流可以同理来代理.

但是该实现有一个问题, 就是一些控制字符没办法发送. 比如tab键, 上述方式没办法指引服务端在收到tab按键时自动补全, 又比如你在远端运行vim, 上下左右键也会导致输出怪样. 解决方法是将stdin切换至Raw模式:

import (
	"log"
	"os"

	"golang.org/x/crypto/ssh/terminal"
)

inFD := int(os.Stdin.Fd())
state, err := terminal.MakeRaw(inFD)
if err != nil {
	log.Fatalf("failed to make raw: %s", err)
}
defer terminal.Restore(inFD, state)

窗口大小

窗口大小也是一个用户体验的重要一方面, 不然你打开vim, 只能使用办个屏幕岂不是很无奈. 我们上面知道, 在请求虚拟终端的时候, 可以指定窗口大小, 所以我们只要把客户端的窗口大小发送给服务端就可以了:

import (
	"log"

	"golang.org/x/crypto/ssh/terminal"
)

width, height, err := terminal.GetSize(inFD)
if err != nil {
	log.Printf("failed to get terminal size: %s", err)
	return
}

初始大小我们可以通过把width和height通过http参数传给服务端.

import (
	"github.com/gorilla/websocket"
)

dialer := websocket.Dialer{}
conn, resp, err := dialer.Dial("ws://<host>:<host>/<path>?width=<width>&height=<height>", httpHeaders)

可是我们经常调整窗口大小, 比如一会全屏, 一会半屏, 我们怎么通知服务端呢?

winch := make(chan os.Signal, 1)
signal.Notify(winch, unix.SIGWINCH)
defer signal.Stop(winch)

for {
	select {
	case <-winch:
		width, height, err := terminal.GetSize(inFD)
		if err != nil {
			fmt.Fprintf(os.Stderr, "failed to get terminal size: %s", err)
			return
		}
		// update the message to remote
		conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("%d,%d", width, height)))
	case <-stopCh:
		return
	}
}

也就是我们可以通过signal来获悉窗口大小改变事件, 每次发生该事件时, 我们重新获取窗口大小, 并将新的大小信息通过websocket.TextMessage来发送给服务端, 而在服务端可以处理该消息, 并且调整会话终端大小:

mt, r, err := conn.NextReader()
// we only take window change text message
if mt == websocket.TextMessage {
	sizeBytes, err := ioutil.ReadAll(r)
	if err != nil {
		continue
	}

	sizes := strings.Split(string(sizeBytes), ",")
	if len(sizes) != 2 {
		continue
	}

	width, werr := strconv.ParseInt(sizes[0], 10, 32)
	height, herr := strconv.ParseInt(sizes[1], 10, 32)
	if werr != nil || herr != nil {
		continue
	}

	err = session.WindowChange(int(height), int(width))
	if err != nil {
		log.Printf("failed to change window: %s", err)
	}
}

至此, 我们已经理清楚了要实现一个ssh代理所需要知道的所有细节了.

总结

那回到开头, 为什么说看完此文也就顺带理解了kubectl exec的实现原理了呢, 因为如果我们把WebSocket换成SPDY协议, 再把SSH协议换成container runtime的输入输出流, 整个过程就几乎一模一样了.

那做这样一个SSH Proxy有啥用处呢?

  1. SSH认证可以与RBAC集成. 我们说过客户端在发起连接时可以发送额外的HTTP头, 比如说Bearer Token. 这样的话, 该代理服务器可以通过TokenReview来获取用户身份, 再用SubjectAccessReview来判断该用户是否有登录机器权限.
  2. 甚至更酷一点的是, 有权限登录机器的人可以产生一个临时的登录链接, 该链接可以share给指定的人临时登录机器, 是不是很酷嘞?

那还有什么理由使用teleport呢?