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

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

安全 4062 字 / 9 分钟
AI 总结 以下AI总结内容由腾讯混元大模型生成

本文主要讨论了作者使用 Go 编写 HTTP(S) 代理的经历和心得。文章开头提到了作者喜欢的音游《Muse Dash》的周免机制,引发了他利用这一机制进行中间人攻击的思考。接着,作者详细介绍了 HTTP 代理的两种类型:普通代理和隧道代理,并分享了使用 Go 实现 HTTP 代理的过程。最后,文章探讨了 HTTPS 代理的实现原理,并对比了 HTTP 和 HTTPS 代理的差异。

  1. 起因是氪不起曲包:作者在《Muse Dash》的更新中发现了一个周免机制,这让他想到了利用这一机制进行中间人攻击的可能性。

  2. HTTP 代理:文章详细介绍了 HTTP 代理的两种类型:普通代理和隧道代理,并展示了如何使用 Go 编写 HTTP 代理的过程。

  3. 处理 HTTP 请求:作者分享了自己实现的 ServeHTTP 方法,用于处理 HTTP 请求,并讨论了如何根据请求方式选择走 HTTP 代理还是 HTTPS 代理。

  4. HTTPS 代理:文章探讨了 HTTPS 代理的实现原理,并对比了 HTTP 和 HTTPS 代理的差异,包括使用隧道代理和直接使用代理服务器的区别。

起因是氪不起曲包

我很喜欢的音游——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 文档的基础上,再去翻翻源码,未尝不是一种好的学习方式。

谢谢老板 Thanks♪(・ω・)ノ


喜欢这篇文章?为什么不打赏一下呢?