这是 Relay Observatory 代理项目实现系列的第一篇。完整代码放在 GitHub。
很多 HTTP 代理教程只贴一个 ServeHTTP,却没有解释为什么 HTTPS 要使用 CONNECT、Hijack 返回的连接和缓冲区分别是什么,以及流量到底应该在哪一层统计。本文从连接模型开始,把这些问题串起来。
先建立正确的连接模型
正向代理同时持有两侧连接:
客户端 <------ client connection ------> 代理
代理 <------ upstream connection ----> 目标服务器
对普通 HTTP,代理能够解析请求和响应:
客户端 -- HTTP Request --> 代理 -- HTTP Request --> 目标
客户端 <- HTTP Response -- 代理 <- HTTP Response -- 目标
对 HTTPS,HTTP 内容在 TLS 内部已经加密。代理不能先读取 GET /,因为客户端在发送 HTTP 请求前必须与目标服务器完成 TLS 握手。
解决办法是客户端先向代理发送明文的 CONNECT:
CONNECT example.com:443 HTTP/1.1
Proxy-Authorization: Basic ...
代理连接 example.com:443,返回:
HTTP/1.1 200 Connection Established
此后代理只搬运 TLS 二进制数据,不解析内部 HTTP。
入口只做三件事
入口 Handler 最容易写乱。一个清楚的入口应该只负责:
- 鉴权;
- 判断普通 HTTP 还是 CONNECT;
- 将流量统一入账。
func (p *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
identity, ok := auth.AuthenticateBasicHeader(
p.users,
r.Header.Get("Proxy-Authorization"),
)
if !ok {
w.Header().Set("Proxy-Authenticate", `Basic realm="proxy"`)
http.Error(w, "proxy authentication required", 407)
return
}
protocol := traffic.ProtocolHTTP
var uploaded, downloaded int64
if r.Method == http.MethodConnect {
protocol = traffic.ProtocolHTTPS
uploaded, downloaded = p.forwardTunnel(w, r)
} else {
uploaded, downloaded = p.forwardHTTP(w, r, identity.Username)
}
_ = p.traffic.Record(identity.ID, protocol, uploaded, downloaded)
}
http.MethodConnect 只是字符串常量 "CONNECT"。这里不是在判断网站是否以 https:// 开头,而是在判断客户端是否要求建立 TCP 隧道。
普通 HTTP:RoundTripper 完成一次往返
Go 的 http.RoundTripper 是一次 HTTP 请求的底层接口:
type RoundTripper interface {
RoundTrip(*http.Request) (*http.Response, error)
}
它完成:
代理 -- Request --> 目标
代理 <- Response -- 目标
代理使用 RoundTripper 而不是高级的 http.Client,因为代理不应该替用户自动跟随重定向、保存 Cookie 或改变请求语义。
转发前需要整理请求:
func prepareUpstreamRequest(r *http.Request) {
if r.URL.Scheme == "" {
r.URL.Scheme = "http"
}
if r.URL.Host == "" {
r.URL.Host = r.Host
}
// 客户端请求使用 RequestURI;RoundTripper 要求它为空。
r.RequestURI = ""
// 代理密码绝不能继续发给目标网站。
r.Header.Del("Proxy-Authorization")
removeHopByHopHeaders(r.Header)
}
然后发送请求并把响应流式复制回客户端:
response, err := transport.RoundTrip(r)
if err != nil {
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer response.Body.Close()
copyHeaders(w.Header(), response.Header)
w.WriteHeader(response.StatusCode)
downloaded, err := io.Copy(w, response.Body)
io.Copy 不会把整个文件读进内存。即使目标返回几 GB 文件,也只需要一个固定大小的缓冲区:
目标返回一块 -> 代理读取一块 -> 立即写给客户端
Hop-by-Hop Header 为什么不能转发
Connection、Keep-Alive、Proxy-Authorization、Transfer-Encoding 等 Header 只描述当前一跳连接。
例如客户端与代理希望保持长连接,不代表代理与目标服务器必须使用同样的连接策略。因此这些 Header 必须在进入下一跳前删除。
还要处理 Connection 中动态声明的字段:
for _, value := range header.Values("Connection") {
for name := range strings.SplitSeq(value, ",") {
header.Del(strings.TrimSpace(name))
}
}
只删除固定列表而忽略 Connection: Foo,会把本应仅对当前连接有效的 Foo 转发出去。
CONNECT:先连接目标,再接管客户端连接
第一步是代理主动连接目标:
upstream, err := dialer.DialContext(
r.Context(),
"tcp",
r.Host,
)
如果请求是:
CONNECT example.com:443 HTTP/1.1
那么 r.Host 是 example.com:443。DialContext 会完成 DNS 查询与 TCP 三次握手,并返回代理到目标服务器的 upstream 连接。
第二步是从 net/http 手中接管客户端连接:
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking is not supported", 500)
return
}
client, buffered, err := hijacker.Hijack()
这里有三个容易混淆的对象:
| 对象 | 含义 |
|---|---|
client | 客户端到代理的底层 TCP 连接 |
upstream | 代理到目标服务器的 TCP 连接 |
buffered | client 前面的缓冲读取器,不是第三条连接 |
net/http 可能提前从客户端读取了一部分数据放进缓冲区。上传方向必须先读 buffered,否则会漏掉已经离开内核 socket、但还没交给业务代码的数据。
两个 io.Copy 组成全双工隧道
上传和下载必须同时进行:
go func() {
uploaded, _ := io.Copy(upstream, buffered)
results <- uploaded
}()
go func() {
downloaded, _ := io.Copy(client, upstream)
results <- downloaded
}()
如果按顺序执行:
io.Copy(upstream, client)
io.Copy(client, upstream)
第一行通常要等客户端关闭连接才返回,第二行永远没有机会及时执行,下载方向就被阻塞了。
当一个方向结束时,不应该立刻关闭整条 TCP 连接,而应优先使用半关闭:
if tcp, ok := connection.(*net.TCPConn); ok {
_ = tcp.CloseWrite()
}
CloseWrite 发送 FIN,表示“我不会再发送”,但仍允许把另一个方向尚未完成的数据读完。
流量统计应该统计哪一层
普通 HTTP 可以统计请求体和响应体:
uploaded = request body bytes
downloaded = response body bytes
请求体通过装饰器计数:
type countingReadCloser struct {
io.ReadCloser
bytes int64
}
func (r *countingReadCloser) Read(p []byte) (int, error) {
n, err := r.ReadCloser.Read(p)
r.bytes += int64(n)
return n, err
}
HTTPS CONNECT 无法看到内部请求体,因此统计的是 TLS 隧道原始字节。它会包含:
- TLS 握手;
- 证书;
- TLS record 开销;
- 加密后的 HTTP Header 和 Body。
所以 curl %{size_download} 与代理下载量不会完全相等。前者通常只统计响应 Body,后者统计整条加密隧道。出现约千分之几的差异是正常现象。
用接口隔离认证和统计
代理层不应该知道密码存储在 .env、SQLite 还是远程服务:
type Authenticator interface {
Authenticate(username, password string) (Identity, bool)
}
type Recorder interface {
Record(
userID int64,
protocol string,
uploaded int64,
downloaded int64,
) error
}
这样网络层只负责协议,存储层可以从内存平滑替换为 SQLite,测试也能使用轻量内存实现。
小结
Go HTTP 正向代理的核心不是某个库,而是两套不同的数据路径:
普通 HTTP:
Request -> RoundTripper -> Response -> io.Copy
HTTPS:
CONNECT -> Dial -> Hijack -> 双向 io.Copy
一旦分清 client、upstream、buffered 和两个方向的数据流,鉴权、流量统计、持久化都只是围绕主路径增加的边界能力。
