Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Protocol Upgrade from HTTP to WebSocket, and Proxy SSH #35

Open
zhouhaibing089 opened this issue Jul 22, 2019 · 0 comments
Open

Protocol Upgrade from HTTP to WebSocket, and Proxy SSH #35

zhouhaibing089 opened this issue Jul 22, 2019 · 0 comments
Labels

Comments

@zhouhaibing089
Copy link
Owner

zhouhaibing089 commented Jul 22, 2019

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呢?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant