本文讲解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 mainimport ( "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" ) 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 mainimport ( "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) 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 connCONNECT 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-aliveConnection : keep-aliveHost : 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 mainimport ( "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) 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" )) 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 mainimport ( "bufio" "crypto/tls" "fmt" "io" "log" "net" "net/http" ) var cert tls.Certificatevar tlsConfig *tls.Configfunc 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) 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() 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:9999。
当然我们也可以自己生成一个,然后导入到浏览器里面,让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.crt和server.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 对这张证书的签名
浏览器拿到这张证书后,会检查几件事:
这张证书是不是可信机构签发的?
证书有没有过期?
证书上的域名是不是 www.taobao.com? 注:如果浏览器直接访问 https://xx.xx.xx.xx,则会检查服务端证书里是否包含xx.xx.xx.xx这个 IP 身份,这个不怎么常用。 一般都是给域名签证书,然后域名解析到地址,而不会直接给地址进行签名。
证书有没有被篡改?
服务器是否真的拥有这张证书对应的私钥?
如果这些检查都通过,浏览器才会认为我现在连接的服务器。
结论:
域名证书:DNS可以决定用户连接到哪个IP,但证书决定这个服务器能不能证明自己是谁。
IP证书(不常用):用户直接访问IP地址时,客户端会验证证书SAN里是否包含这个IP Address身份。
更优雅的架构 在当前的http/https代理架构下,客户端直连代理服务器,但是更常见的架构是:
1 浏览器-->梯子客户端-->梯子服务端->目标网站
这样做的好处是我们可以自己做更多细致的加密,流量控制策略,心跳,重连,连接复用等等功能。并且我们也可以自己做加密,不用所谓的证书了,避免配置https proxy。
我总结一些非常有意义的优化方向:
复杂分流:客户端本地筛选流量,对某些网站的访问走直接,某些网站访问走代理。
协议发现:peek前几个字节,可以知道浏览器使用的协议类型,这样就可以支持单端口多协议。
连接复用:正如我们在前面keep-alive细节讨论中得到的结论所述,http(s)代理没有解决一条长连接队头阻塞问题 ,也就是一条连接在同一时刻只能处理一个请求。但若自己实现梯子客户端,梯子客户端和服务端可以使用少量长连接,复用它们发送来自浏览器的多个请求,并且服务端也可以并发进行请求,这样不但速度更快,也能节省用户侧到梯子服务端的长连接数量。
自定义加密:架构是浏览器--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 ,如果是空仓库就是还没写,总有一天会写的。