本文讲解http代理的原理,以及Go如何实现这样一个代理,希望能帮助读者更好地理解平时使用的翻墙工具。

何为墙,何为梯

由于政策问题,中国大陆网络环境无法访问Google,Youtube等网站。这是因为GFW(也就是Great Firewall of China)的干扰。GFW会使用部署在网络链路上的各种过滤干扰机制来干扰双方通信,包括但不限于丢弃报文,fake假的TCP RST包,返回假的DNS解析结果…也就是说,我们普通人的网络直接访问google.com是访问不通的。

为了解决这个问题,我们可以考虑使用香港/台湾的服务器来中转流量,这种服务器需要搭建在外网环境,满足用户能够访问到服务器且服务器能访问到目标网站的条件。

例如,我想要访问Google,我告诉香港的服务器,我要访问Google,香港服务器帮我访问后返回给我响应结果,这就是完整的一次http请求代理流程。访问HTTPS时,中转服务器未必能看到明文内容,更准确是转发流量或建立隧道,这个略为复杂,后续实现中会详细讲解。

实现上述技术的工具或系统就叫做梯子。梯子有很多种技术实现,本文聚焦http代理。

初探https代理协议:代理http请求

我们先启动一个Go Tcp Server,然后将系统或者浏览器(这里推荐使用Firefox,可以手动设置代理)的http proxy设置为我们的Server。接下来把浏览器发来的信息打印出来。下面是Go Server代码:

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
package main

import (
"io"
"net"
"os"
)

func main() {
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9999})
if err != nil {
panic(err)
}

for {
tcpConn, err := listener.AcceptTCP()
if err != nil {
continue
}

go handleConn(tcpConn)
}
}

func handleConn(conn *net.TCPConn) {
println("new conn")
// keep copy conn to stdout
io.Copy(os.Stdout, conn)
conn.Close()
}

使用浏览器访问http://test.com/。输出如下:

1
2
3
4
5
6
7
8
9
new conn
GET http://test.com/ HTTP/1.1
Host: test.com
Proxy-Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9

可以看到浏览器先会和我们的代理进程建立tcp连接,然后发送想要访问的请求信息。

接下来我们改造一下代理进程,让它能够解析来自浏览器的http请求,先重温http协议:

1
2
3
4
5
请求方法[空格]URL[空格]协议版本[\r][\n]
[header1]:[header_val1][\r][\n]
[header2]:[header_val2][\r][\n]
[\r][\n]
[body数据]

解析代码如下:

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
package main

import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"strings"
)

var fakeResponse = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 20\r\n\r\n<h1>Hello World</h1>"

func main() {
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9999})
if err != nil {
panic(err)
}

for {
tcpConn, err := listener.AcceptTCP()
if err != nil {
continue
}

go handleConn(tcpConn)
}
}

func headerClean(header http.Header) string {
if len(header) == 0 {
return ""
}

builder := strings.Builder{}
for k, v := range header {
builder.WriteString(k + ": " + strings.Join(v, ",") + "\n")
}

result := builder.String()
if result[len(result)-1] == '\n' {
return result[:len(result)-1]
}

return result
}

func handleConn(conn *net.TCPConn) {
println("new conn")
defer conn.Close()
connBufReader := bufio.NewReader(conn)
// ReadRequest only support http 1.x
req, err := http.ReadRequest(connBufReader)
if err != nil {
return
}

body, err := io.ReadAll(req.Body)
if err != nil {
req.Body.Close()
return
}
req.Body.Close()

fmt.Printf("proto: %v\n", req.Proto)
fmt.Printf("url: %v\n", req.URL.String())
fmt.Printf("header:\n%v\n", headerClean(req.Header))
fmt.Printf("body length: %v\n", len(body))
fmt.Println("")

_, err = conn.Write([]byte(fakeResponse))
if err != nil {
println(err)
return
}
}

读者可以自行运行上面代码,并且访问http://test.com/进行测试,并且我们fake了一个假的response,打印出了Hello World

http1.1 keep-alive

想必大家经常看到Connection: keep-alive这个header,上面我们打印的浏览器请求也有Proxy-Connection: keep-alive这个header。这是http1.1新引入的一个特性,也就是复用一条TCP连接。

传统的http1.0使用的模型是: 建立TCP连接->Browser向Server发送Req->Server向Browser回应Resp->关闭TCP连接。而http1.1认为tcp建连接是一个非常高成本的操作,很慢,所以复用一条长连接是很好的,于是引入了Connection头部向服务端请求维持这个长连接。

如果服务端接受keep-alive,则http resp也带上Connection: keep-alive,如果不接受直接Connection: close即可。浏览器的行为则是串行地发送请求->接受响应->发送第二个请求->接收第二个响应…

通常浏览器是会同时请求巨多内容的,即使开启了keep-alive复用,本质也是串行的,一样很慢…为了解决这个问题,浏览器 “同时发很多请求”,靠6~8 条并行 TCP 连接,而不是单连接批量发。

Proxy-Connection: keep-alive代表浏览器想要和代理客户端保持长连接,一般合理的代理客户端应该实现它,我上面的Hello World程序是不合理的,没有keep-alive。

在http2.0引入了但tcp连接多路复用,浏览器可一次性发几十个请求,不用等任何响应,代理/服务器可按处理完的顺序返回响应,不用按请求顺序,二进制帧格式,请求/响应交错收发,彻底打破串行。本文就不讨论http2.0了,专注http1.x。

初探http代理: 代理https请求

代理https请求会复杂的多,我们一样先访问https://test.com/看看。输出如下:

1
2
3
4
5
6
new conn
CONNECT test.com:443 HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0
Proxy-Connection: keep-alive
Connection: keep-alive
Host: test.com:443

可以看到这里是一个神秘的CONNECT请求,它的语义是: 为浏览器和目标网站建立一条tcp隧道。代理只负责双向转发流量,tls是浏览器与目标网站进行协商,所以代理看不到明文,也改不了,非常安全。当浏览器想要访问https网站时就会用这个隧道功能,后续浏览器自行和目标网站协商tls握手,然后进行双向tls通讯,不论是运营商还是代理服务器,都对通信内容本身一无所知。

下面代码展示了Go如何优雅实现这样的双向转发:

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
82
83
84
85
package main

import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"strings"
)

func main() {
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9999})
if err != nil {
panic(err)
}

for {
tcpConn, err := listener.AcceptTCP()
if err != nil {
continue
}

go handleConn(tcpConn)
}
}

func headerClean(header http.Header) string {
if len(header) == 0 {
return ""
}

builder := strings.Builder{}
for k, v := range header {
builder.WriteString(k + ": " + strings.Join(v, ",") + "\n")
}

result := builder.String()
if result[len(result)-1] == '\n' {
return result[:len(result)-1]
}

return result
}

func handleConn(conn *net.TCPConn) {
println("new conn")
defer func() {
fmt.Println("close")
}()
defer conn.Close()
connBufReader := bufio.NewReader(conn)
// ReadRequest only support http 1.x
req, err := http.ReadRequest(connBufReader)
if err != nil {
return
}

_, err = io.ReadAll(req.Body)
if err != nil {
req.Body.Close()
return
}
req.Body.Close()

if req.Method != "CONNECT" {
var fakeResponse = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 20\r\n\r\n<h1>Hello World</h1>"
_, err = conn.Write([]byte(fakeResponse))
if err != nil {
return
}
return
}

conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
// CONNECT, 建立转发
remoteConn, err := net.Dial("tcp", req.Host)
if err != nil {
return
}
go func() {
io.Copy(remoteConn, conn)
}()
io.Copy(conn, remoteConn)
}

如果设置了http代理并且访问https://www.baidu.com就直接能访问到网页了。

http代理存在的问题

我们当前的http代理架构如下:

1
2
3
4
5
浏览器
↓ 明文 HTTP 代理协议/CONNECT要求建立隧道
代理服务器
↓ HTTP请求/TCP隧道
目标网站

这里有两个问题:

  • 对于HTTP请求代理,比如GET www.baidu.com,浏览器到代理服务器这边是来回是明文的,也就是运营商完全知道你的意图,GFW能轻松看到你们的请求来回拦截流量。
  • 对于HTTPS请求,虽然建立了隧道,GFW和代理服务器都没办法知道具体的请求内容,但是浏览器向代理服务器发送CONNECT www.youtubu.com也是一个很明显的信号,GFW也能轻易知道你的意图加以拦截。

究其根本实际上是浏览器到代理服务器的流量来回没有加密

为了解决这个问题,引入https代理,所谓https代理,实际上就是浏览器到目的服务器这一层的tcp加上了tls握手,其它地方没任何改变,这里我实现一个https代理:

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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
package main

import (
"bufio"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
)

var cert tls.Certificate
var tlsConfig *tls.Config

func main() {
var err error
cert, err = tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("load cert failed: %v", err)
}

tlsConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}

listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9999})
if err != nil {
panic(err)
}

for {
tcpConn, err := listener.AcceptTCP()
if err != nil {
fmt.Println(err)
continue
}

println("new conn")
go handleConn(tcpConn)
}
}

func handleConn(conn *net.TCPConn) {
defer conn.Close()
tlsConn := tls.Server(conn, tlsConfig)
defer tlsConn.Close()

if err := tlsConn.Handshake(); err != nil {
println(err.Error())
return
}

fmt.Println("tls success")

connBufReader := bufio.NewReader(tlsConn)
// ReadRequest only support http 1.x
req, err := http.ReadRequest(connBufReader)
if err != nil {
return
}

if req.Method != http.MethodConnect {
_, err := io.Copy(io.Discard, req.Body)
if err != nil {
req.Body.Close()
return
}
req.Body.Close()
var fakeResponse = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 20\r\n\r\n<h1>Hello World</h1>"
_, err = tlsConn.Write([]byte(fakeResponse))
if err != nil {
return
}
return
}

if _, err := io.Copy(io.Discard, req.Body); err != nil {
return
}
req.Body.Close()

// CONNECT, 建立转发
remoteConn, err := net.Dial("tcp", req.Host)
if err != nil {
_, _ = tlsConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"))
return
}
defer remoteConn.Close()

_, err = tlsConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
if err != nil {
return
}

done := make(chan struct{}, 2)

go func() {
_, _ = io.Copy(remoteConn, tlsConn)
done <- struct{}{}
}()

go func() {
_, _ = io.Copy(tlsConn, remoteConn)
done <- struct{}{}
}()

<-done
}

使用了https代理,实际上就是客户端到浏览器使用了TLS握手。但是实际上去Firefox配置127.0.0.1:9999的https代理后依然无法访问,原因是这个签出来的证书不被信任。如果有域名,这里推荐使用Let’s Entrypt来给自己的域名进行签发,然后域名再解析到127.0.0.1

当然我们也可以自己生成一个,然后导入到浏览器里面,让Firefox直接信任这个证书。follow下面的步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// CA 私钥,自己保存
> openssl genrsa -out ca.key 4096

// CA 证书,可以公开
> openssl req -x509 -new -nodes \
-key ca.key \
-sha256 \
-days 3650 \
-out ca.crt \
-subj "/CN=Local Dev Proxy CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash"

// 服务器私钥,自己保存
> openssl genrsa -out server.key 2048

然后创建server.cnf,写入下面的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no

[req_distinguished_name]
CN = localhost

[v3_req]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1

接下来执行命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
> openssl req -new \
-key server.key \
-out server.csr \
-config server.cnf

// 用 CA 签发服务端证书,可以公开
> openssl x509 -req \
-in server.csr \
-CA ca.crt \
-CAkey ca.key \
-CAcreateserial \
-out server.crt \
-days 825 \
-sha256 \
-extensions v3_req \
-extfile server.cnf

// 检查证书 SAN
> openssl x509 -in server.crt -noout -text | grep -A2 "Subject Alternative Name"
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:127.0.0.1
X509v3 Subject Key Identifier:

我们就有了server.crtserver.key,也就是程序里LoadX509KeyPair需要的那两个文件。接下来Firefox需要导入ca.crt文件:

进入设置->隐私与安全->证书->管理证书->证书颁发机构->导入选我们的ca.crt即可。

接下来代理设置->配置代理选中自动代理配置的 URL(PAC)w,输入data:,function FindProxyForURL(url, host) { return "HTTPS 127.0.0.1:9999"; },这代表使用https代理。

关于证书

证书可以理解为一个身份认证。想象这样一个场景:证书可以理解为一个身份认证。想象这样一个场景:

你是一个DNS攻击者,你想要把“淘宝”重定向到你的“黑马电商”狠狠赚一笔。于是用户访问https://www.taobao.com。

正常情况下,DNS应该告诉用户www.taobao.com对应真正淘宝服务器的IP。但你偷偷做了手脚,让很多人的dns请求拿成了你自己的服务器IP:www.taobao.com → 你的黑马电商服务器 IP。

这样用户的浏览器确实会被你带到你的服务器。但是问题来了:浏览器不只是看“我连到了哪个 IP”,它还会问服务器:你怎么证明你就是 www.taobao.com?这个时候,服务器需要拿出一张“证书”。

比如淘宝的服务器会拿出一张证书,上面写着:

1
2
3
4
5
这个证书属于:www.taobao.com
签发机构:某个受信任的 CA
有效期:某年某月某日到某年某月某日
公钥:一串公开的加密材料
签名:CA 对这张证书的签名

浏览器拿到这张证书后,会检查几件事:

  1. 这张证书是不是可信机构签发的?
  2. 证书有没有过期?
  3. 证书上的域名是不是 www.taobao.com?
    注:如果浏览器直接访问 https://xx.xx.xx.xx,则会检查服务端证书里是否包含xx.xx.xx.xx这个 IP 身份,这个不怎么常用。
    一般都是给域名签证书,然后域名解析到地址,而不会直接给地址进行签名。
  4. 证书有没有被篡改?
  5. 服务器是否真的拥有这张证书对应的私钥?

如果这些检查都通过,浏览器才会认为我现在连接的服务器。

结论:

  • 域名证书:DNS可以决定用户连接到哪个IP,但证书决定这个服务器能不能证明自己是谁。
  • IP证书(不常用):用户直接访问IP地址时,客户端会验证证书SAN里是否包含这个IP Address身份。

更优雅的架构

在当前的http/https代理架构下,客户端直连代理服务器,但是更常见的架构是:

1
浏览器-->梯子客户端-->梯子服务端->目标网站

这样做的好处是我们可以自己做更多细致的加密,流量控制策略,心跳,重连,连接复用等等功能。并且我们也可以自己做加密,不用所谓的证书了,避免配置https proxy。

我总结一些非常有意义的优化方向:

  1. 复杂分流:客户端本地筛选流量,对某些网站的访问走直接,某些网站访问走代理。
  2. 协议发现:peek前几个字节,可以知道浏览器使用的协议类型,这样就可以支持单端口多协议。
  3. 连接复用:正如我们在前面keep-alive细节讨论中得到的结论所述,http(s)代理没有解决一条长连接队头阻塞问题,也就是一条连接在同一时刻只能处理一个请求。但若自己实现梯子客户端,梯子客户端和服务端可以使用少量长连接,复用它们发送来自浏览器的多个请求,并且服务端也可以并发进行请求,这样不但速度更快,也能节省用户侧到梯子服务端的长连接数量。
  4. 自定义加密:架构是浏览器--http代理协议-->本地代理客户端--自定义加密-->远端代理服务器的架构,其中本地代理客户端到远端代理服务器可以自定义加密隧道协议。且对于之前遇到的Firefox不信任证书的问题也可以解决。

QUIC协议与证书申请

梯子客户端到梯子服务端的通讯被称为隧道,这里可以考虑使用QUIC或者TCP TLS。

QUIC强制要求使用TLS握手,非常安全,且一条QUIC连接支持多路逻辑连接,标准术语叫streams(流)。从设计的角度来说QUIC很适合作为加密隧道。

为了使用QUIC,我们最好去买一个域名并且用Let’s Entrypt申请一张证书,具体参考这个:https://docs.dnspod.cn/dns/acme-sh/

最后我们能拥下列文件:

1
ca.cer  fullchain.cer  markity.cn.cer  markity.cn.conf  markity.cn.csr  markity.cn.csr.conf  markity.cn.key

使用cert, err := tls.LoadX509KeyPair("xxx.cer", "xxx.key")

最终实现

参见github:https://github.com/markity/crush-proxy,如果是空仓库就是还没写,总有一天会写的。