从 TCP 到 HTTP:用 Go 实现带鉴权与流量统计的正向代理

这是 Relay Observatory 代理项目实现系列的第一篇。完整代码放在 GitHub

很多 HTTP 代理教程只贴一个 ServeHTTP,却没有解释为什么 HTTPS 要使用 CONNECTHijack 返回的连接和缓冲区分别是什么,以及流量到底应该在哪一层统计。本文从连接模型开始,把这些问题串起来。

先建立正确的连接模型

正向代理同时持有两侧连接:

客户端 <------ 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 最容易写乱。一个清楚的入口应该只负责:

  1. 鉴权;
  2. 判断普通 HTTP 还是 CONNECT;
  3. 将流量统一入账。
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 为什么不能转发

ConnectionKeep-AliveProxy-AuthorizationTransfer-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.Hostexample.com:443DialContext 会完成 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 连接
bufferedclient 前面的缓冲读取器,不是第三条连接

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

一旦分清 clientupstreambuffered 和两个方向的数据流,鉴权、流量统计、持久化都只是围绕主路径增加的边界能力。

下一篇继续实现 SOCKS5 的 CONNECT、BIND 与 UDP ASSOCIATE