[Go] 不到 100 行代码实现一个支持 CONNECT 动词的 HTTP 服务器
在 HTTP 诸多的动词中,有一个不常用、不热门的,它是 CONNECT。
跟在它后面的资源,通常不是像 GET/POST 一样的资源路径,如:GET /path/to/file
。而是一个主机:端口
对,即TCP地址、四元组的一半、第三方,如:CONNECT github.com:443
。
如果该 HTTP 服务器支持 CONNECT 动词,比如淘宝的基于 nginx 修改后的 tengine 中的 proxy_connect
模块,
那么,它将连接到该地址。如果连接成功,它将返回200
状态码给客户端。
**此后,所有的流量数据完全透传。**这样就相当于在客户端和第三方之间打通了一个隧道(Tunnel)。 你可能在公司经常用SSH跳板机登录到服务器上去,这个跳板机也充当了隧道建立者的角色。为什么要用跳板机而不是直接登录?因为为了保证服务器的安全,只允许少量的IP可以访问。同时也提高了性能(iptables)。
这里我借用一下淘宝的 proxy_connect 模块的工作方式序列图,它非常形象地说明了数据的流向:
curl nginx (proxy_connect) github.com
| | |
(1) |-- CONNECT github.com:443 -->| |
| | |
| |----[ TCP connection ]--->|
| | |
(2) |<- HTTP/1.1 200 ---| |
| Connection Established | |
| | |
| |
========= CONNECT tunnel has been established. ===========
| |
| | |
| | |
| [ SSL stream ] | |
(3) |---[ GET / HTTP/1.1 ]----->| [ SSL stream ] |
| [ Host: github.com ] |---[ GET / HTTP/1.1 ]-->.
| | [ Host: github.com ] |
| | |
| | |
| | |
| | [ SSL stream ] |
| [ SSL stream ] |<--[ HTTP/1.1 200 OK ]---'
(4) |<--[ HTTP/1.1 200 OK ]------| [ < html page > ] |
| [ < html page > ] | |
| | |
注:
- 原文 establish 的过去分词错误拼写为 establesied,已修正
为了测试可用性,以及学习用Go语言编写网络程序,我试着完整地写了这样一个程序,不足 100 行(加上注释和空行)。
代码地址:https://gist.github.com/movsb/74e9a91b07e9f76e6c78224f8158f4ee
这段代码非常的简洁,仅两个函数。
main
函数建立了一个 HTTP 服务器,把请求的处理函数指向了tunnel
函数。
tunnel
函数是一个标准的 HTTP 处理器函数。有两个参数:一个请求,一个响应。
接下来分解这个函数的实现。
// We handle CONNECT method only
if req.Method != http.MethodConnect {
log.Println(req.Method, req.RequestURI)
http.NotFound(w, req)
return
}
这段代码表示我们仅处理 CONNECT 动词,其它的动词通通返回404 未找到
。
// Proxy-Authorization is set by client software.
// Authorization is used by req.BasicAuth().
req.Header.Set("Authorization", req.Header.Get("Proxy-Authorization"))
user, pass, ok := req.BasicAuth()
if !ok || !(user == username && pass == password) {
log.Println("bad credential.", "user:", user, "pass:", pass)
// Don't let them know we support CONNECT.
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
这里是用户校验部分。由于客户端的授权是放在Proxy-Authorization
中的,而req.BasicAuth
依赖的是Authorization
,所以我们简单重新设置一下。
然后取得用户名和密码,并判断是否与预设一致。若不一致,返回错误。
// The host:port pair.
log.Println(req.RequestURI)
这句话输出第三方的地址。
// Connect to Remote.
dst, err := net.Dial("tcp", req.RequestURI)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer dst.Close()
然后我们尝试连接到第三方,如果连接失败,返回错误。否则继续往下。
// Upon success, we respond a 200 status code to client.
w.Write(connectResponse)
到这里就已经连接第三方成功了,我们应该告诉客户端。它是一个普通的200
状态码响应。
// Now, Hijack the writer to get the underlying net.Conn.
// Which can be either *tcp.Conn, for HTTP, or *tls.Conn, for HTTPS.
src, bio, err := w.(http.Hijacker).Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer src.Close()
这一步非常关键。何为hijack
?我们知道,HTTP 是应用层协议,在它的下一层,是 TCP 网络层协议。
hijack
方法让我们可以从响应(Response)
中拿到这个 TCP 连接。非常关键的一个函数。
这个函数返回两个可读可写的对象。src
是TCP连接(如果是HTTPS服务器,则是TLS连接),bio
是对 src
包装的一个带缓冲的读写者。
wg := &sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
// The returned bufio.Reader may contain unprocessed buffered data from the client.
// Copy them to dst so we can use src directly.
if n := bio.Reader.Buffered(); n > 0 {
n64, err := io.CopyN(dst, bio, int64(n))
if n64 != int64(n) || err != nil {
log.Println("io.CopyN:", n64, err)
return
}
}
// Relay: src -> dst
io.Copy(dst, src)
}()
go func() {
defer wg.Done()
// Relay: dst -> src
io.Copy(src, dst)
}()
wg.Wait()
这段是核心代码。创建了两个线程,然后调用io.Copy
进行全双工的双向数据拷贝(中继)。
从src
到dst
的前面多了一段对带缓冲对象的处理,因为带缓冲,可能有未读完的数据,所以先确保全部读走。目的是为了能直接使用src
。
不过,用bio
代替src
也是可以的,只是看上去效率应该会低一些。另外,如果是往bio
里面写数据,记得适时调用bio.Flush()
将数据刷走,否则可能会“假死”。
像下面这样运行并作为服务器:
$ go run tunnel.go
# 或者
$ go build -o tunnel tunnel.go
$ ./tunnel
可以在 cURL 中测试是否可以工作:
$ curl -p --proxy my_username:my_password@localhost:18080 http://www.example.com
cURL 在目标为 HTTP 而非 HTTPS 时会使用 GET
去请求。-p
可以使其总是使用CONNECT
。
不出意外,服务器会打印出请求的第三方地址,cURL 会输出页面内容。
注:为了保证数据安全、防监听、插入广告,请在服务器上使用 HTTPS,勿使用 HTTP。即使用ListenAndServeTLS
代替ListenAndServe
。
为了查阅方便,附上完整代码:
package main
import (
"io"
"log"
"net"
"net/http"
"sync"
)
var (
listen = "localhost:18080"
connectResponse = []byte("HTTP/1.1 200 OK\r\n\r\n")
username = "my_username"
password = "my_password"
)
func tunnel(w http.ResponseWriter, req *http.Request) {
// We handle CONNECT method only
if req.Method != http.MethodConnect {
log.Println(req.Method, req.RequestURI)
http.NotFound(w, req)
return
}
// Proxy-Authorization is set by client software.
// Authorization is used by req.BasicAuth().
req.Header.Set("Authorization", req.Header.Get("Proxy-Authorization"))
user, pass, ok := req.BasicAuth()
if !ok || !(user == username && pass == password) {
log.Println("bad credential.", "user:", user, "pass:", pass)
// Don't let them know we support CONNECT.
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// The host:port pair.
log.Println(req.RequestURI)
// Connect to Remote.
dst, err := net.Dial("tcp", req.RequestURI)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer dst.Close()
// Upon success, we respond a 200 status code to client.
w.Write(connectResponse)
// Now, Hijack the writer to get the underlying net.Conn.
// Which can be either *tcp.Conn, for HTTP, or *tls.Conn, for HTTPS.
src, bio, err := w.(http.Hijacker).Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer src.Close()
wg := &sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
// The returned bufio.Reader may contain unprocessed buffered data from the client.
// Copy them to dst so we can use src directly.
if n := bio.Reader.Buffered(); n > 0 {
n64, err := io.CopyN(dst, bio, int64(n))
if n64 != int64(n) || err != nil {
log.Println("io.CopyN:", n64, err)
return
}
}
// Relay: src -> dst
io.Copy(dst, src)
}()
go func() {
defer wg.Done()
// Relay: dst -> src
io.Copy(src, dst)
}()
wg.Wait()
}
func main() {
handler := http.HandlerFunc(tunnel)
err := http.ListenAndServe(listen, handler)
if err != http.ErrServerClosed {
panic(err)
}
}