go语言100行代码撸一个http(s)代理

这段时间一直在研究GoProxy-VPS的源码,苦于技术有限,参悟不透,索性从最简单的开始学起,用Go语言写一个功能最简单的http(s)代理。参考了HTTP(S) Proxy in Golang in less than 100 lines of code这篇文件,用少于100行代码撸一个Go语言的http(s)代理服务器。

HTTP

首先来看一下比较简单的HTTP代理的原理,它负责接收客户端发起的请求,然后将请求转发给目标服务器,从目标服务器那边得到响应后,再将响应内容转发给客户端。整个流程十分简单,可以用一个图来概括。

HTTPS(HTTP连接隧道)

比起简单的HTTP代理,HTTPS代理稍显复杂。如果说HTTP协议传输的时候都是明文传输的话,那么HTTPS协议则是传输双方确认对方身份后,以加密地形式传送消息,比HTTP协议更加安全也更不容易被人窃取。这一切都建立在SSL协议之上,传输双方建立起了一个安全的HTTP连接隧道。

HTTPS代理原理需要在代理服务器和目标服务器之间建立TCP连接,然后客户端和代理服务器之间也要建立TCP连接,这样才能确保整个传输过程的安全性。在建立完成HTTP隧道之后,剩下的就和HTTP传输没什么区别了。整个过程可以概括为下图

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package main
import (
"crypto/tls"
"flag"
"io"
"log"
"net"
"net/http"
"time"
)
func handleTunneling(w http.ResponseWriter, r *http.Request) {
dest_conn, err := net.DialTimeout("tcp", r.Host, 10*time.Second)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
client_conn, _, err := hijacker.Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
}
go transfer(dest_conn, client_conn)
go transfer(client_conn, dest_conn)
}
func transfer(destination io.WriteCloser, source io.ReadCloser) {
defer destination.Close()
defer source.Close()
io.Copy(destination, source)
}
func handleHTTP(w http.ResponseWriter, req *http.Request) {
resp, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
func main() {
var pemPath string
flag.StringVar(&pemPath, "pem", "path to pem file", "server.pem")
var keyPath string
flag.StringVar(&keyPath, "key", "path to key file", "server.key")
var proto string
flag.StringVar(&proto, "proto", "https", "https")
flag.Parse()
if proto != "http" && proto != "https" {
log.Fatal("Protocol must be either http or https")
}
server := &http.Server{
Addr: ":8080",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
handleTunneling(w, r)
} else {
handleHTTP(w, r)
}
}),
// Disable HTTP/2.
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
}
if proto == "http" {
log.Fatal(server.ListenAndServe())
} else {
log.Fatal(server.ListenAndServeTLS(pemPath, keyPath))
}
}

以上源码仅限于示例,不适合生产环境下使用

当我们的代理服务器接收到来自客户端的请求后,它会根据请求的方法来决定是采用HTTP代理,还是HTTPS代理。代码是这么做的

1
2
3
4
5
6
7
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
handleTunneling(w, r)
} else {
handleHTTP(w, r)
}
})

其中handleHTTP方法就是HTTP代理,首先将客户端的请求发往目标服务器,得到目标服务器的响应后,将响应头和内容拷贝到自己的响应中,最后返回给客户端。代码如下

1
2
3
4
5
6
7
8
9
10
11
func handleHTTP(w http.ResponseWriter, req *http.Request) {
resp, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

而handleTunneling方法则是HTTPS代理,也称为HTTP安全隧道代理。第一步,客户端将请求发送到代理服务器后,由代理服务器和目标服务器建立起TCP连接。

下一步,代理服务器和客户端建立TCP连接后,代理服务器扮演一个拦截者的角色,在客户端和目标服务器之间拦截信息。拦截的代码如下

1
2
3
4
5
6
7
8
9
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
client_conn, _, err := hijacker.Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
}

最后等两个TCP连接都完成之后(从客户端到代理服务器,从代理服务器到目标服务器),就需要建立HTTP隧道了,以下代码实现

1
2
go transfer(dest_conn, client_conn)
go transfer(client_conn, dest_conn)

在Go路由中,拷贝数据应该是双向的,从客户端到目标服务器,反之亦然。

证书

为什么要把证书放到最后面才讲,因为它最容易引起混淆。证书是确保客户端和服务器端身份安全的基础。我们这个例子属于双向SSL认证,不了解的可以参考我的这篇文章,简述https的认证过程。这篇文章里已经阐明了https认证的原理。

我们需要生成一张自签的证书和一份私钥。可以使用以下脚本来生成,取名为self-signed-cert.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env bash
case `uname -s` in
Linux*) sslConfig=/etc/ssl/openssl.cnf;;
Darwin*) sslConfig=/System/Library/OpenSSL/openssl.cnf;;
esac
openssl req \
-newkey rsa:2048 \
-x509 \
-nodes \
-keyout server.key \
-new \
-out server.pem \
-subj /CN=localhost \
-reqexts SAN \
-extensions SAN \
-config <(cat $sslConfig \
<(printf '[SAN]\nsubjectAltName=DNS:localhost')) \

-sha256 \
-days 3650

修改这段脚本的属性

1
chown 775 self-signed-cert.sh

接下来就可以直接执行了,该脚本会生成两个文件,分别为server.pem以及server.key。解释一下,server.pem就是证书,而server.key则是私钥。这两个文件就是在刚才的代码中用到的,注意它们的路径别写错了。

1
2
3
4
var pemPath string
flag.StringVar(&pemPath, "pem", "server.pem", "path to pem file")
var keyPath string
flag.StringVar(&keyPath, "key", "server.key", "path to key file")

此时,如果你编译完成之后,想要查看你写的这个代理是否正常工作,需要先在浏览器上设置代理。我使用的是Chrome的插件SwitchyOmega。

然后访问网址https://localhost:8080,你会发现浏览器提示你网站不安全。原因是因为代理服务器需要验证客户端上传的证书和代理服务器上的是否一致。也就是server.pem这个证书文件。由于客户端没有这个证书,所以网站就提示不安全。那么怎么才能让客户端上传server.pem这个证书文件呢?

打开Mac下钥匙串访问,然后将server.pem文件拖到登录里的证书。证书名称会显示为localhost。然后右键点击显示简介,修改为始终信任即可。

再访问一下刚才的网址试试看,是不是显示为安全了。再试一下别的HTTPS的网站以及HTTP的网站,是不是都能正常访问呢。

自此一个HTTP(S)代理服务器成功。

总结

虽然刚接触Go语言没多久,但是还是有很多不理解的地方,比方说代码里的hijacker拦截。为什么代码里要禁用HTTP/2?因为拦截的功能貌似不支持HTTP/2。但是还是可以看出Go语言其实是很方便的,尤其是在跨平台编译这方面有着天然优势。刚才这段代码稍微修改了一下证书路径,然后编译上传到我的VPS上,竟然也能翻墙,让我感到小小的成就感,原来我也可以写一个简单的翻墙软件。

avatar

chilihotpot

You Are The JavaScript In My HTML