关于我用 Go 写 HTTP(S) 代理这档事

起因是氪不起曲包

我很喜欢的音游——Muse Dash,在 10 月 28 日的时候发布了万圣节更新。新的游戏更新里加入了“周免”机制。即每周都会有一首付费曲包里的歌曲供玩家免费试玩,这样玩家就会有动力氪金买曲包了。
Muse Dash 10-28 更新

但是我这阵子手头一直不宽裕,比赛的钱最迟有要到下学期才发的…… 助手那边最近也没啥锅让我写。氪金当然是不可能氪金的啦。
不过自“周免”机制推出后,我就隐隐有一种预感:我可以用这个周免机制搞些事情。果不其然,周免机制的实现是游戏客户端请求服务器 API 来获取本周的免费歌曲,然后再将本地游戏的歌曲设置成免费状态。好巧不巧,用的还是 HTTP 协议请求接口!这下连证书的事情都免了,完全可以做个中间人攻击修改传回来的数据包,从而实现任意歌曲“周免”免费畅玩!
我先用 Charles 试着改了一下,果然可行!接下来的打算就是用 Go 写一个 HTTP 代理,然后将手机上的代理设置成 Go 的即可。
话不多说,冲冲冲!

HTTP 代理

首先先上最终成品:https://github.com/wuhan005/MuseDash_Free_Player,可以看到我之后还给它写了个 Web 图形界面,让我可以手动选歌。选完歌曲后重启游戏即可。
MuseDash_Free_Player
今天我想聊的是其中十分重要的一个部分—— 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)
}

这下就很明白了,当判断Handlernil时,会使用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上锁,然后检验入参patternhandler是否为空。然后定义一个muxEntry结构体的变量e来存储路由信息。之后将其添加到m的 map 中,这样patternhandler的键值对就建立好了。之后收到用户请求时,就可以直接从该 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)
有坑注意
这里需要将传入的`*Request`的`RequestURI`设置为空。这是因为对于客户端来说,代理是服务端,因此在服务端的获得的请求信息中,会含有`RequestURI`来表示请求的URI。而代理作为客户端向远程的服务器发送请求时,URI 的信息是放在请求的 URL 里进行传输的,而`RequestURI`是空的。

这样我们也可以向远程服务器发送 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()的入参的顺序是dstsrc,Go 中好像很多这种复制操作都是被复制的目标放在前面。
dstWriter类型,srcReader类型,其实也都是interface

type Writer interface {
    Write(p []byte) (n int, err error)
}
type Reader interface {
    Read(p []byte) (n int, err error)
}

然后serverclient变量都是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)
}

这边其实可以看到大面积的使用了interfacestruct。一层套一层,其实也就实现了继承。

HTTP Hijacker??

那么既然我们可以使用 Hijacker 来用隧道代理实现 HTTPS 的代理,那么是否也可以实现 HTTP 代理呢。答案是可以的。只是不能直接复用 HTTPS 代理的代码。因为需要向服务端发送一个 CONNECT 请求来建立 TCP 链接。然而不同的是 HTTP 连接直接就是发送请求包了。因此不能直接复用代码。同时,因为使用隧道代理会有一次往返建立隧道的开销,因此能不用就尽量不用。

综上,这就是我写这个代理时陆陆续续了解到的东西。发现其实 Go 的包源码还是挺有意思的,很多实现其实勉勉强强还能看得懂。在看 godoc 文档的基础上,再去翻翻源码,未尝不是一种好的学习方式。

3 条评论

昵称
  1. 1e1e

    膜大茄子!!

  2. dm2q

    何止是强!

  3. 荒唐书生

    大茄子太强了!