关于我用 Go 写 HTTP(S) 代理这档事
起因是氪不起曲包
我很喜欢的音游——Muse Dash,在 10 月 28 日的时候发布了万圣节更新。新的游戏更新里加入了“周免”机制。即每周都会有一首付费曲包里的歌曲供玩家免费试玩,这样玩家就会有动力氪金买曲包了。
但是我这阵子手头一直不宽裕,比赛的钱最迟有要到下学期才发的…… 助手那边最近也没啥锅让我写。氪金当然是不可能氪金的啦。 不过自“周免”机制推出后,我就隐隐有一种预感:我可以用这个周免机制搞些事情。果不其然,周免机制的实现是游戏客户端请求服务器 API 来获取本周的免费歌曲,然后再将本地游戏的歌曲设置成免费状态。好巧不巧,用的还是 HTTP 协议请求接口!这下连证书的事情都免了,完全可以做个中间人攻击修改传回来的数据包,从而实现任意歌曲“周免”免费畅玩! 我先用 Charles 试着改了一下,果然可行!接下来的打算就是用 Go 写一个 HTTP 代理,然后将手机上的代理设置成 Go 的即可。 话不多说,冲冲冲!
HTTP 代理
首先先上最终成品:https://github.com/wuhan005/MuseDash_Free_Player,可以看到我之后还给它写了个 Web 图形界面,让我可以手动选歌。选完歌曲后重启游戏即可。 今天我想聊的是其中十分重要的一个部分—— HTTP(S) 代理。
HTTP 代理分为两种:
普通代理:这种代理也就是扮演着“中间人”的角色。对于用户的客户端来说,代理是服务端;对于远程的服务端来说,代理是客户端。代理也就是将用户的请求做了转发,真正请求服务器的是代理。服务器从而也就无法知道用户客户端的真实 IP 了。
隧道代理:这种代理能够**以 HTTP 的方式实现任意基于 TCP 的应用层协议代理。**你可以使用它来实现任何基于 TCP 协议的代理,典型的就像 WebSocket 这种。它是先通过 HTTP 的 CONNECT 方法请求服务端,然后在客户端与服务端间建立起一条 TCP 连接,之后对客户端与服务器之间的代理进行无脑转发(盲转发)。
其中 HTTP 代理我使用的是普通代理,因为我作为中间人可以读取到客户端发来的请求包,然后再转发给服务端。服务端传回来的响应包我也可以读到,因此可以修改其中的数据再返回给客户端。 而 HTTPS 代理则是使用隧道代理,因为 HTTPS 就是防止像我这样的中间人攻击的呀!
管中窥豹http
包
下面开始聊一下代码中我挖掘到的有意思的点。
这是开启代理并监听端口的第一版代码。
bindAddr := p.Config.Address + ":" + p.Config.Port
log.Fatalln(http.ListenAndServe(bindAddr, p))
可以注意到这里也是启了一个 HTTP 服务器,但是却与我们平时用 Go 原生的http
包写 Web 服务器的代码不同:
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "Hello John!")
})
_ = http.ListenAndServe(":8080", nil)
一般写 Web 服务器时,我们都会使用HandleFunc
来指定路由以及处理路由的函数。ListenAndServe
的第二个参数Handle
的值为nil
。
感到十分好奇的我去看了下 Go 内置http
包的源码。
跟进ListenAndServe
的定义:
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
发现其创建了一个Server
结构体,并运行了其ListenAndServe
方法。
http
包中定义的Server
结构体是来表示运行着的 HTTP 服务器。
type Server struct {
Addr string // TCP address to listen on, ":http" if empty 需要监听的 IP:Port
Handler Handler // handler to invoke, http.DefaultServeMux if nil 处理路由的 Handler,当为 nil 时是使用 http.DefaultServeMux。
TLSConfig *tls.Config
ReadTimeout time.Duration
ReadHeaderTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
TLSNextProto map[string]func(*Server, *tls.Conn, Handler)
ConnState func(net.Conn, ConnState)
ErrorLog *log.Logger
BaseContext func(net.Listener) context.Context
ConnContext func(ctx context.Context, c net.Conn) context.Context
disableKeepAlives int32 // accessed atomically.
inShutdown int32 // accessed atomically (non-zero means we're in Shutdown)
nextProtoOnce sync.Once // guards setupHTTP2_* init
nextProtoErr error // result of http2.ConfigureServer if used
mu sync.Mutex
listeners map[*net.Listener]struct{}
activeConn map[*conn]struct{}
doneChan chan struct{}
onShutdown []func()
}
同时我们在server.go L2799
还可以找到ServeHTTP
函数:
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
if req.RequestURI == "*" && req.Method == "OPTIONS" {
handler = globalOptionsHandler{}
}
handler.ServeHTTP(rw, req)
}
这下就很明白了,当判断Handler
为nil
时,会使用http
包中默认定义的DefaultServeMux
来存储路由信息。
之后跟进定义路由的HandleFunc
方法:
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
可以看到它确实将我们的路由信息添加到了默认的DefaultServeMux
变量中。
DefaultServeMux
变量是一个ServeMux
结构体,ServeMux
结构体用来存储我们定义的路由信息的。
type ServeMux struct {
mu sync.RWMutex // 读写锁
m map[string]muxEntry // 存储路由信息的 map
es []muxEntry // 将路由信息从长到短排列的切片
hosts bool // 路由字符串中是否有属于主机名的部分
}
type muxEntry struct {
h Handler // 处理路由的方法
pattern string
}
func (mux *ServeMux) Handle(pattern string, handler Handler)
方法实现了添加路由的整个过程。
func (mux *ServeMux) Handle(pattern string, handler Handler) {
mux.mu.Lock()
defer mux.mu.Unlock()
if pattern == "" {
panic("http: invalid pattern")
}
if handler == nil {
panic("http: nil handler")
}
if _, exist := mux.m[pattern]; exist {
panic("http: multiple registrations for " + pattern)
}
if mux.m == nil {
mux.m = make(map[string]muxEntry)
}
e := muxEntry{h: handler, pattern: pattern}
mux.m[pattern] = e
if pattern[len(pattern)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
if pattern[0] != '/' {
mux.hosts = true
}
}
先是使用mu
上锁,然后检验入参pattern
和handler
是否为空。然后定义一个muxEntry
结构体的变量e
来存储路由信息。之后将其添加到m
的 map 中,这样pattern
和handler
的键值对就建立好了。之后收到用户请求时,就可以直接从该 map 中通过pattern
取出对应的handler
执行就好。
之后判断路由是否是以/
结尾的。如果是,则将其放入mux.es
中。这里的appendSorted
函数使用了二分查找,将e
插入到mux.es
,并满足从大到小的顺序。
我寻思这么排序的方式是为了后面可以从 0 开始遍历最长(最深)的路由。
以上是使用默认的DefaultServeMux
来存储路由信息时背后发生的一些事情。
那么像我上面写的代理,将p
这个结构体传入第二个参数Handler
的话,会怎样呢?
我们跟进去看下Handler
的定义:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
原来如此!Handler
其实是一个interface
,里面只有ServeHTTP
这一个方法。因此,我在传入的结构体中定义了ServeHTTP
方法,之后就会去调用结构体中的该方法。(早就听闻 Go 源码中关于interface
使用的是炉火纯青,久闻公之大名,今日有幸相会)
处理 HTTP 请求
那么就来看看我自己定义的ServeHTTP
吧:
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 判断协议类型
if r.Method != "CONNECT" {
p.HTTPHandler(w, r)
} else {
p.HTTPSHandler(w, r)
}
}
这里根据客户端的 HTTP 请求方式,来判断了是要走 HTTP 代理还是 HTTPS 代理。 这是因为浏览器通过代理使用 HTTPS 协议时,会先发送一个 CONNECT 方法的请求,来告诉服务端创建一条到服务器的 TCP 链接。这个请求是可以被中间人抓包拿到的,但是因为里面只包含了客户端的 UA 等信息,并不会存在有不安全因素。
func (p *Proxy) HTTPHandler(w http.ResponseWriter, r *http.Request){
transport := http.DefaultTransport
outReq := new(http.Request) // 用于转发请求
*outReq = *r // 复制客户端请求
// 发送请求
res, err := transport.RoundTrip(outReq)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(err.Error()))
return
}
// 返回信息给客户端
// 返回状态码
w.WriteHeader(res.StatusCode)
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(res.Body)
backBody := buf.String()
// 返回请求体
_, _ = io.WriteString(w, backBody)
_ = res.Body.Close()
}
在HTTPHandler
中,我在网上找到的教程是使用http.DefaultTransport
中的RoundTrip
来转发请求。然而我并没有太搞懂这个函数主要是做什么用的。
其实这里说白了就是要把http.Request
类型的变量进行处理了。使用 Go 原生的 HTTP 发送请求一般会写类似resp, err := http.Get("http://httpbin.org/get")
这样的代码。我们可以跟进http.Get
这个方法:
func Get(url string) (resp *Response, err error) {
return DefaultClient.Get(url)
}
再继续跟进Get()
func (c *Client) Get(url string) (resp *Response, err error) {
req, err := NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
return c.Do(req)
}
可以看到这里使用了NewRequest
来生成一个*Request
类型的请求结构体,然后使用Client
里面的Do()
方法来发送。那么模仿着这个,上面使用RoundTrip
来转发请求的操作,其实也可以这样写:
c := http.Client{}
outReq.RequestURI = ""
resp, err := c.Do(outReq)
这样我们也可以向远程服务器发送 HTTP 请求,之后把返回的响应体中的内容再io.WriteString
写回ResponseWriter
即可。
HTTPS 代理
HTTPS 代理使得我们无法获取服务端与客户端之间的报文明文信息,因此使用上文中提到的隧道代理。
Go 语言的http
中有一个Hijacker
。它可以让请求的发起者接管之后的 TCP 链接,而不再通过 Go 的Handler
函数。这其实就是隧道代理。
值得注意的是,再与远端服务器建立好 TCP 链接后,需要向客户端发送一个200 Connection Established
的信息,以此告诉客户端连接已建立,可以开始传输加密的报文了。
_, _ = client.Write([]byte("HTTP/1.0 200 Connection Established\r\n\r\n"))
之后就是将服务端和客户端之间的信息进行双向传递即可。
// 直通双向复制
go io.Copy(server, client)
go io.Copy(client, server)
这个io.Copy()
其实又是对于接口的妙用,嘻嘻。跟进去看一下:
func Copy(dst Writer, src Reader) (written int64, err error) {
return copyBuffer(dst, src, nil)
}
这里首先是要注意一下Copy()
的入参的顺序是dst
和src
,Go 中好像很多这种复制操作都是被复制的目标放在前面。
dst
的Writer
类型,src
的Reader
类型,其实也都是interface
:
type Writer interface {
Write(p []byte) (n int, err error)
}
type Reader interface {
Read(p []byte) (n int, err error)
}
然后server
与client
变量都是net.Conn
这个interface
。简单的跟一下:
server, err := net.Dial("tcp", host)
// dial.go L316
func Dial(network, address string) (Conn, error) {
var d Dialer
return d.Dial(network, address)
}
// dial.go L346
func (d *Dialer) Dial(network, address string) (Conn, error) {
return d.DialContext(context.Background(), network, address)
}
//dial.go L424
c, err = sd.dialSerial(ctx, primaries)
// dial.go L546
c, err := sd.dialSingle(dialCtx, ra)
// dial.go L575
switch ra := ra.(type) {
case *TCPAddr:
la, _ := la.(*TCPAddr)
c, err = sd.dialTCP(ctx, la, ra)
case *UDPAddr:
la, _ := la.(*UDPAddr)
c, err = sd.dialUDP(ctx, la, ra)
case *IPAddr:
la, _ := la.(*IPAddr)
c, err = sd.dialIP(ctx, la, ra)
case *UnixAddr:
la, _ := la.(*UnixAddr)
c, err = sd.dialUnix(ctx, la, ra)
default:
....
// tcpsock.go L85
type TCPConn struct {
conn
}
// net.go L171
type conn struct {
fd *netFD
}
// fd_unix.go L201
func (fd *netFD) Read(p []byte) (n int, err error) {
n, err = fd.pfd.Read(p)
runtime.KeepAlive(fd)
return n, wrapSyscallError("read", err)
}
这边其实可以看到大面积的使用了interface
与struct
。一层套一层,其实也就实现了继承。
HTTP Hijacker??
那么既然我们可以使用 Hijacker 来用隧道代理实现 HTTPS 的代理,那么是否也可以实现 HTTP 代理呢。答案是可以的。只是不能直接复用 HTTPS 代理的代码。因为需要向服务端发送一个 CONNECT 请求来建立 TCP 链接。然而不同的是 HTTP 连接直接就是发送请求包了。因此不能直接复用代码。同时,因为使用隧道代理会有一次往返建立隧道的开销,因此能不用就尽量不用。
综上,这就是我写这个代理时陆陆续续了解到的东西。发现其实 Go 的包源码还是挺有意思的,很多实现其实勉勉强强还能看得懂。在看 godoc 文档的基础上,再去翻翻源码,未尝不是一种好的学习方式。
喜欢这篇文章?为什么不打赏一下呢?