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。