Protocol Upgrade from HTTP to WebSocket, and Proxy SSH
Opened this issue · 0 comments
Check this out: https://github.com/zhouhaibing089/ssh-over-websocket
实在不知道起个什么名, 就罗列了一堆看似牛逼的术语. 对于此文呢, 我也实在无法理出章法, 故但求随意, 倘若读者能稍微学到一点东西, 或是因此而有个什么创新的想法, 我便觉得此文有点意义, 心中便有些许满足.
简单来说呢, 通过阅读此文, 你可能会收获:
- 知晓如何写一个http server, 它能做protocol upgrade.
- 了解如何用go写一个ssh client.
- 可能最重要的是: 熟悉
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
之后, 客户端的调用方式和服务端的调用方式无异, 皆为NextReader
和WriteMessage
.
SSH Client
得益于golang.org/x/crypto/ssh这个库, 我们可以非常轻松地构建一个ssh客户端. 简单来说, 创建一个ssh会话包括以下几个步骤:
- 准备认证信息: 常见的又用户名/密码或者私钥.
- 创建连接.
- 创建会话.
- 请求一个虚拟终端.
认证信息
我们以私钥为例:
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有啥用处呢?
- SSH认证可以与RBAC集成. 我们说过客户端在发起连接时可以发送额外的HTTP头, 比如说Bearer Token. 这样的话, 该代理服务器可以通过TokenReview来获取用户身份, 再用SubjectAccessReview来判断该用户是否有登录机器权限.
- 甚至更酷一点的是, 有权限登录机器的人可以产生一个临时的登录链接, 该链接可以share给指定的人临时登录机器, 是不是很酷嘞?
那还有什么理由使用teleport呢?