CONNECT 请求代理建立隧道,并不让代理从数据路径上消失。准入成功之后,代理仍然向两个方向转发字节,并管理套接字、缓冲区和故障处理。CONNECT 本身不提供加密;HTTPS 是在这条隧道内部另行建立并验证 TLS 会话,不能把两个阶段的成功混为一谈。
在正确的边界切换语义
HTTP/1.1 的 CONNECT 目标是 server.example:443 这样的主机与端口组合,而不是普通路径或完整 HTTPS URL。端口必须明确给出。成功的 2xx 响应在响应头结束后立即切换到隧道模式;非成功响应则表示隧道尚未建立,此时不能直接开始把后续内容当作目标服务的数据。
成功的 CONNECT 响应不能携带 Content-Length 或 Transfer-Encoding。随后出现的字节属于隧道,而不是一份用长度界定的 HTTP 响应正文。自己实现中继时,这一点尤其重要:HTTP 解析器已经缓冲的字节也可能属于隧道,不能因为切换到原始套接字,就把此前预读的数据丢掉。
客户端随后可以穿过中继,与目标协商 TLS。代理返回 200 只说明允许并建立了隧道,不负责证明目标证书有效,也不证明应用请求成功。客户端仍须检查信任链、主机名与应用响应。TLS 拦截属于另一种部署方式,需要客户端信任会终止 TLS 的中间实体,不能把它与普通盲转发混为同一种安全模型。
看不见正文,仍然占用资源
盲中继不必解释加密的应用内容,但仍能观察目标地址、字节量、时序和关闭行为。它可以落实连接预算、超时、流量限制,以及撤销准入之后的断开操作。无法读取 TLS 内部的 HTTP 消息,不等于失去所有运营控制,更不等于不再承担带宽和描述符成本。
真正的正向代理应验证调用方身份,并限制目的地。任意主机和端口访问,可能暴露内部服务,或被用于其他协议的滥用。按主机名放行时,还必须考虑地址解析策略:通过名称检查,并不自动证明最终拨号的 IP 地址符合约束。本地实验只允许一个预先确定的环回地址与端口,因此没有引入任意目标的解析和转发能力。
坏掉的样子
代理可能在上游尚未连通时就返回 200,可能丢弃解析器缓冲的字节,也可能在待转发数据尚未送出时,过早关闭另一个方向。这些错误看起来可能像证书配置正确却仍然失败的 TLS 握手。排障时,应分别记录准入失败、隧道传输失败、TLS 身份验证失败和源站 HTTP 失败。
Go 的 HTTP/1.1 连接一旦被 Hijack,生命周期就交给处理函数负责,普通 HTTP 服务端不会再管理它。请求上下文也不能代替隧道清理,需要显式管理两个复制方向和截止时间。普通服务端停机不会自动关闭已经接管的连接,生产停机需要记录这些连接,并显式排空或关闭,避免遗漏长连接。固定的绝对期限限制的是连接总寿命,并不是每收到一批流量就自动续期的空闲超时;两者不能使用同一个解释。
快速验证
保存为 connect_probe.go,使用 Go 1.23 或更新版本运行 go run connect_probe.go。实验建立环回 HTTP/1.1 代理和 TLS 1.3 源站,只允许源站的精确主机端口组合。客户端信任这台测试服务的证书,不关闭证书验证,也不修改系统信任库。
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"net/url"
"runtime"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var handled, admitted, denied, connectStatus atomic.Int64
origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
check(r.TLS != nil && r.TLS.Version == tls.VersionTLS13, "origin requires TLS 1.3")
fmt.Fprint(w, "through the tunnel")
}))
origin.Config.ErrorLog = log.New(io.Discard, "", 0) // Expected wrong-name handshake failure.
origin.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
origin.StartTLS()
defer origin.Close()
target, err := url.Parse(origin.URL)
must(err)
relayed := make(chan [2]int64, 3)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
http.Error(w, "CONNECT required", http.StatusMethodNotAllowed)
return
}
if r.Host != target.Host || r.URL.Host != target.Host {
denied.Add(1)
http.Error(w, "target denied", http.StatusForbidden)
return
}
upstream, err := net.DialTimeout("tcp", target.Host, time.Second)
must(err)
defer upstream.Close()
downstream, buffered, err := w.(http.Hijacker).Hijack()
must(err)
defer downstream.Close()
admitted.Add(1)
deadline := time.Now().Add(3 * time.Second)
must(upstream.SetDeadline(deadline))
must(downstream.SetDeadline(deadline))
_, err = buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n")
must(err)
must(buffered.Flush())
upDone := make(chan int64, 1)
go func() {
count, _ := io.Copy(upstream, buffered) // Preserve already-buffered tunnel bytes.
_ = upstream.(*net.TCPConn).CloseWrite()
upDone <- count
}()
count, _ := io.Copy(downstream, upstream)
_ = downstream.(*net.TCPConn).CloseWrite()
relayed <- [2]int64{<-upDone, count}
}))
defer proxy.Close()
proxyURL, err := url.Parse(proxy.URL)
must(err)
config := origin.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL), TLSClientConfig: config, DisableKeepAlives: true,
OnProxyConnectResponse: func(_ context.Context, _ *url.URL, _ *http.Request, response *http.Response) error {
connectStatus.Store(int64(response.StatusCode))
if response.StatusCode == 200 {
check(response.Header.Get("Content-Length") == "" && response.Header.Get("Transfer-Encoding") == "", "successful CONNECT must not frame a body")
}
return nil
},
}
defer transport.CloseIdleConnections()
get := func(tr *http.Transport, address string) (*http.Response, error) {
return (&http.Client{Transport: tr, Timeout: 2 * time.Second}).Get(address)
}
success := func(label string) {
response, err := get(transport, origin.URL)
must(err)
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.StatusCode == 200 && string(body) == "through the tunnel", "wrong origin response")
check(connectStatus.Load() == 200 && len(response.TLS.VerifiedChains) > 0, "CONNECT and verified TLS required")
fmt.Println(label, "CONNECT=200; verified origin response")
}
success("allowed")
response, err := get(transport, "https://denied.invalid:443/")
check(err != nil && response == nil && connectStatus.Load() == 403, "proxy must reject the target")
check(denied.Load() == 1 && admitted.Load() == 1 && handled.Load() == 1, "denial must not open an upstream tunnel")
fmt.Println("denied CONNECT=403; no upstream admission")
wrong := transport.Clone()
wrong.TLSClientConfig = config.Clone()
wrong.TLSClientConfig.ServerName = "wrong.invalid"
defer wrong.CloseIdleConnections()
response, err = get(wrong, origin.URL)
var hostnameError x509.HostnameError
check(response == nil && errors.As(err, &hostnameError) && connectStatus.Load() == 200, "client must verify the tunneled TLS identity")
check(handled.Load() == 1, "wrong-name TLS must not reach the HTTP handler")
fmt.Println("wrong name CONNECT=200; TLS rejected before origin HTTP")
success("recovery")
check(handled.Load() == 2 && admitted.Load() == 3, "wrong final origin/tunnel counts")
for index := 0; index < 3; index++ {
select {
case counts := <-relayed:
check(counts[0] > 0 && counts[1] > 0, "proxy must relay bytes in both directions")
case <-time.After(4 * time.Second):
panic("relay did not finish")
}
}
fmt.Println("three completed relays; two successful origin HTTP requests")
fmt.Println(runtime.Version())
}
第一次请求记录 CONNECT 200,并读到完整的源站正文。denied.invalid 目标得到 CONNECT 403,没有新增上游准入;实验不会解析这个名称。第三次尝试虽然得到 CONNECT 200,却在证书主机名验证阶段失败,源站 HTTP 处理次数不变。最后用正确配置再次请求,恢复成功。
三条获准隧道都向两个方向转发了字节,但只有两次源站 HTTP 请求完成。DisableKeepAlives 防止其他连接复用掩盖新的 CONNECT 交换;代理响应回调检查实际准入状态,并核对成功响应不含正文分帧字段。程序等待两个方向的复制结束,并显式关闭自行接管的套接字。
已核验的 macOS、Go 1.27.0 运行通过全部断言。请求限时两秒,拨号限时一秒,隧道套接字设置三秒绝对期限。这是隔离的协议实验,不是可以直接部署的公共代理。它没有测试调用方认证、DNS 策略、TLS 拦截、提前注入的缓冲数据、流量整形、持续负载或 HTTP/2、HTTP/3 的 CONNECT 映射,不能据此宣称具备这些生产能力。
延伸阅读
来源
- RFC 9110:HTTP 语义,CONNECT 准入、目标形式、中继职责和目的地约束,已核验 2026-09-09。
- RFC 9112:HTTP/1.1,消息分帧与 CONNECT 隧道切换边界,已核验 2026-09-09。
- Go net/http,代理响应观察与接管连接的生命周期,已核验 2026-09-09。
- Go net,连接截止时间与 TCP 半关闭,已核验 2026-09-09。
- Go crypto/tls,证书验证和 TLS 连接状态,已核验 2026-09-09。