我第一次排查 HTTPS 代理认证时,最容易误判的一点,是把“发给目标站的请求头”和“发给代理的 CONNECT 握手请求头”当成了同一层。代码看起来只是多写了一个 Header.Set,但代理日志里完全没有那一行,最后才发现请求头加错了位置。
如果目标地址是 HTTPS、链路中间是 HTTP 代理,Go 的 http.Transport 会先向代理发起 CONNECT。要给这次 CONNECT 添加固定请求头,用 ProxyConnectHeader;如果请求头要根据代理地址或目标地址变化,用 GetProxyConnectHeader。两者都服务于代理握手,不是普通业务请求的替代品。
官方地址:https://pkg.go.dev/net/http#Transport
迁移时先记住一句话:业务请求头改
req.Header,CONNECT 代理请求头改Transport.ProxyConnectHeader或Transport.GetProxyConnectHeader;动态回调非空时,静态字段不会再参与这次 CONNECT 头的选择。
先确认改动范围:你要改的是哪一段请求
一次“通过 HTTP 代理访问 HTTPS 目标”的请求,至少可以拆成两个观察点:
- 业务请求:连接建立后,在代理隧道里发给最终 HTTPS 站点的请求,例如
GET /api/items。它的请求头由req.Header控制。 - CONNECT 握手:先发给 HTTP 代理的隧道建立请求,例如
CONNECT api.example.com:443 HTTP/1.1。它的额外请求头由ProxyConnectHeader或GetProxyConnectHeader控制。
因此,把 Proxy-Authorization 放到业务请求的 req.Header 里,并不能可靠地替代代理握手认证。反过来,把只应发给业务服务的租户标识放进 CONNECT 头,也会把它暴露给代理。迁移前先把“谁应该看到这个头”写清楚,通常比先改字段更重要。
固定场景:用 ProxyConnectHeader 给 CONNECT 添加静态头
当同一个 http.Transport 总是使用同一类代理策略,可以直接配置 ProxyConnectHeader。下面的示例只放演示值,真实账号和密钥应来自安全配置,不要写进源码或日志。
package main
import (
"encoding/base64" // 用于演示生成代理认证值
"net/http" // 提供 Transport 和 Client
"net/url" // 解析代理地址
"time" // 设置客户端超时
)
func newClient() *http.Client {
proxyURL, _ := url.Parse("http://proxy.example.com:8080") // 示例代理地址
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL), // 让 HTTPS 请求经过 HTTP 代理
ProxyConnectHeader: http.Header{
"X-Proxy-Tenant": []string{"team-a"}, // 发送给代理的固定租户标识
"Proxy-Authorization": []string{
"Basic " + base64.StdEncoding.EncodeToString([]byte("proxy-user:proxy-pass")), // 仅演示格式
},
},
}
return &http.Client{
Transport: transport, // 复用同一套代理和连接池配置
Timeout: 20 * time.Second, // 给整次请求设置上限
}
}
这里的关键不是认证字符串,而是字段位置:ProxyConnectHeader 只描述 Transport 创建 CONNECT 请求时要附加的头。它不会自动把这些头复制到隧道建立后的业务请求中。
如果你的代码只访问普通 HTTP 目标,Transport 通常不需要为目标建立 HTTPS 隧道,也就不会出现同样的 CONNECT 场景。排查“为什么没看到这个头”时,先确认目标是 HTTPS,并且请求确实使用了 HTTP 代理。
变化场景:用 GetProxyConnectHeader 按目标动态选择
当不同代理、不同目标或不同租户需要不同的 CONNECT 头时,使用 GetProxyConnectHeader 更合适。回调可以看到上下文、代理 URL 和目标字符串,然后返回本次握手需要的 http.Header。
package main
import (
"context" // 传递本次请求的取消信号
"fmt" // 构造缺少策略时的错误
"net/http" // 提供 Transport 和 Header
"net/url" // 提供代理 URL 类型
"strings" // 判断目标主机
)
func newDynamicTransport() *http.Transport {
return &http.Transport{
GetProxyConnectHeader: func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error) {
_ = ctx // 示例不读取上下文,但保留取消边界
if proxyURL == nil { // 没有代理 URL 时不生成代理专用头
return nil, nil
}
headers := make(http.Header) // 每次回调返回独立 Header
headers.Set("X-Proxy-Name", proxyURL.Host) // 让代理知道当前策略来源
if strings.HasPrefix(target, "private.example.com:") { // 私有目标使用专用策略
headers.Set("X-Proxy-Tenant", "private")
return headers, nil
}
if strings.HasPrefix(target, "public.example.com:") { // 公共目标使用普通策略
headers.Set("X-Proxy-Tenant", "public")
return headers, nil
}
return nil, fmt.Errorf("没有 CONNECT 代理策略: %s", target) // 明确拒绝未知目标
},
}
}
这个回调有三个容易被忽略的边界:
- 返回
(nil, nil)表示本次 CONNECT 不额外添加头,不等于给业务请求加头。 - 回调返回错误时,Transport 的
RoundTrip会失败;这适合把“没有代理策略”变成明确错误,而不是发出不完整的握手。 - 只要
GetProxyConnectHeader非空,ProxyConnectHeader就会被忽略。两者不是“先合并、再由动态值覆盖”的关系。
旧代码迁移:把“看起来能用”改成“边界明确”
我在迁移旧客户端时,通常会先把现有写法归类,再决定是否需要动态回调。下面这张表可以直接作为改动前的对照单。
| 旧写法或误区 | 实际作用层 | 迁移后的选择 |
|---|---|---|
req.Header.Set("Proxy-Authorization", ...) | 业务请求 | 需要代理握手认证时,改到 ProxyConnectHeader 或动态回调 |
只配置 ProxyConnectHeader,却需要不同目标使用不同策略 | 固定 CONNECT 头 | 改用 GetProxyConnectHeader,在回调中按 proxyURL、target 返回 |
| 同时配置两个字段并期待自动合并 | 两个 CONNECT 配置入口 | 保留一个明确入口;动态回调非空时静态字段会被忽略 |
| 用普通 HTTP 目标验证 CONNECT 头 | 目标没有 HTTPS 隧道 | 使用 HTTPS 目标经过 HTTP 代理的场景验证 |
| 把代理专用租户头当作业务头复用 | 代理边界与业务边界混用 | 按接收方拆分 Header,避免把内部策略泄漏给最终站点 |
如果只是把代理地址中的用户名和密码写进 URL,也要单独确认这是不是你要的行为。它和自定义 CONNECT 头是两条配置路径;不要因为某个代理支持 URL 认证,就假设它会替你设置所有自定义头。
回归检查:只盯住握手边界和错误边界
迁移后的检查不需要把整套客户端重写。可以围绕下面几个事实逐项确认:
- 目标 URL 是 HTTPS,代理 URL 是 HTTP,测试场景确实会产生 CONNECT。
- 需要发给代理的头出现在
ProxyConnectHeader或GetProxyConnectHeader返回值中,而不是只出现在req.Header。 - 动态回调能根据
proxyURL和target得到稳定结果;未知目标应明确返回错误或明确返回空头。 - 没有同时依赖两个入口的隐式合并;代码注释或配置说明应写清“动态回调优先”。
- 日志只记录代理策略名称、目标类别和状态,不记录真实认证值。
如果需要观察 CONNECT 的响应,可以使用 Transport 提供的 OnProxyConnectResponse 回调记录状态码和必要的非敏感上下文。它适合辅助定位代理拒绝、认证失败或目标不可达,但不要在日志里输出 Proxy-Authorization 等敏感头。
迁移清单
- 先写清这个头应该被代理看到,还是应该被最终 HTTPS 服务看到。
- 固定值选择
ProxyConnectHeader,按目标变化选择GetProxyConnectHeader。 - 动态回调非空时,不再把静态字段当作兜底合并源。
- 对回调错误、空头和未知目标分别定义行为。
- 用 HTTPS 目标经过 HTTP 代理来回归 CONNECT 场景。
- 认证值只从安全配置读取,日志和示例都使用脱敏值。
常见问题
req.Header 里的代理认证头会自动出现在 CONNECT 中吗?
不要这样假设。req.Header 描述的是业务请求;CONNECT 握手头应由 Transport 的代理专用配置提供。
ProxyConnectHeader 对普通 HTTP 目标也生效吗?
它针对 CONNECT 握手。普通 HTTP 代理转发不等同于建立 HTTPS 隧道,不能用普通 HTTP 目标来证明 CONNECT 头已经按预期发送。
两个字段都设置时,会把 Header 合并吗?
当 GetProxyConnectHeader 非空时,静态的 ProxyConnectHeader 会被忽略。需要合并时,应在动态回调中显式构造最终的 Header。
动态回调返回错误有什么意义?
它可以把“当前代理或目标没有可用策略”变成明确的请求失败,避免客户端带着缺失认证或错误租户信息继续建立隧道。

Go testing.Short 怎样让慢测试只在完整模式运行