1. 代理协议
这里讨论的代理协议是用于隐藏访问者的正向代理,更准确说是 HTTP 代理与 SOCKS 代理。印象里最早是在 Chrome 上安装红杏来提供代理访问谷歌,当时尚不清楚它的工作机制,后来在 Ubuntu 上遇到网络问题,开始使用 privoxy 转换 SOCKS5 为 HTTP 代理时,开始接触到这几个环境变量:HTTP_PROXY、HTTPS_PROXY、NO_PROXY。
这里会简单讨论下常用的 HTTP/HTTPS 及 SOCKS5 代理协议的工作机制。
我最早接触的代理工具是 Proxy SwitchySharp 以及后来的 SwitchyOmega,它们都以插件的形式运行在 Chrome 上,我一度以为所有的网络请求都是经过插件转发的,直到后来看到了一个 issue:Feature proposal: SOCKS5 over TLS #1838。提出 issue 的人希望 SwitchyOmega 能支持 TLS 加密的 SOCKS5 代理,但有一个 Contributor 回答:
- SwitchyOmega 只是一款代理服务器切换软件,和翻墙无关。
- SwitchyOmega 的核心其实是 PAC,不支持代理协议的转换。
也就是说 SwitchyOmega 并不转发请求,而是告诉 Chrome 当前要访问的网站需要走直连还是代理,走代理时使用哪一个代理服务器,使用代理服务器的主体是 Chrome 本身。
回到命令行中,当时我在配置好 PAC 后发现网络请求无法使用代理更新软件源,于是设置了几个环境变量
export HTTP_PROXY="http://127.0.0.1:8118" HTTPS_PROXY="http://127.0.0.1:8118"
export NO_PROXY="localhost,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
然后再次测试使用 apt、curl 及其他命令行工具,可以通过代理服务器访问了。
所以说,代理服务器的使用者是发起网络连接的应用程序。
PAC 文件实际上是一个 JavaScript 文件,包含 FindProxyForURL(url, host) 方法用于判断目标地址是否应该直连还是走代理,主要用于浏览器,这里不多做讨论,让我们看看普通应用程序如何使用代理。
以 Go 语言为例,我们可以在 runtime 网络代码模块中看到读取环境变量的地方:
// FromEnvironment returns a Config instance populated from the
// environment variables HTTP_PROXY, HTTPS_PROXY and NO_PROXY (or the
// lowercase versions thereof).
//
// The environment values may be either a complete URL or a
// "host[:port]", in which case the "http" scheme is assumed. An error
// is returned if the value is a different form.
func FromEnvironment() *Config {
return &Config{
HTTPProxy: getEnvAny("HTTP_PROXY", "http_proxy"),
HTTPSProxy: getEnvAny("HTTPS_PROXY", "https_proxy"),
NoProxy: getEnvAny("NO_PROXY", "no_proxy"),
CGI: os.Getenv("REQUEST_METHOD") != "",
}
}
其中 NO_PROXY 会在初始化阶段被解析为一个域名匹配器,用于判断哪些请求不需要走代理:
// useProxy reports whether requests to addr should use a proxy,
// according to the NO_PROXY or no_proxy environment variable.
// addr is always a canonicalAddr with a host and port.
func (cfg *config) useProxy(addr string) bool {
if len(addr) == 0 {
return true
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if host == "localhost" {
return false
}
ip := net.ParseIP(host)
if ip != nil {
if ip.IsLoopback() {
return false
}
}
addr = strings.ToLower(strings.TrimSpace(host))
if ip != nil {
for _, m := range cfg.ipMatchers {
if m.match(addr, port, ip) {
return false
}
}
}
for _, m := range cfg.domainMatchers {
if m.match(addr, port, ip) {
return false
}
}
return true
}
到了发起网络连接时,会读取 HTTP_PROXY 与 HTTPS_PROXY 中保存的代理服务器信息(分别在连接 HTTP 和 HTTPS 站点时使用),先连接上代理服务器,将目标服务器地址发送给代理服务器,再由代理服务器连接上目标服务器。
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
...
// Proxy setup.
switch {
case cm.proxyURL == nil:
// Do nothing. Not using a proxy.
case cm.proxyURL.Scheme == "socks5":
conn := pconn.conn
d := socksNewDialer("tcp", conn.RemoteAddr().String())
if u := cm.proxyURL.User; u != nil {
auth := &socksUsernamePassword{
Username: u.Username(),
}
auth.Password, _ = u.Password()
d.AuthMethods = []socksAuthMethod{
socksAuthMethodNotRequired,
socksAuthMethodUsernamePassword,
}
d.Authenticate = auth.Authenticate
}
if _, err := d.DialWithConn(ctx, conn, "tcp", cm.targetAddr); err != nil {
conn.Close()
return nil, err
}
case cm.targetScheme == "http":
pconn.isProxy = true
if pa := cm.proxyAuth(); pa != "" {
pconn.mutateHeaderFunc = func(h Header) {
h.Set("Proxy-Authorization", pa)
}
}
case cm.targetScheme == "https":
conn := pconn.conn
var hdr Header
if t.GetProxyConnectHeader != nil {
var err error
hdr, err = t.GetProxyConnectHeader(ctx, cm.proxyURL, cm.targetAddr)
if err != nil {
conn.Close()
return nil, err
}
} else {
hdr = t.ProxyConnectHeader
}
if hdr == nil {
hdr = make(Header)
}
if pa := cm.proxyAuth(); pa != "" {
hdr = hdr.Clone()
hdr.Set("Proxy-Authorization", pa)
}
connectReq := &Request{
Method: "CONNECT",
URL: &url.URL{Opaque: cm.targetAddr},
Host: cm.targetAddr,
Header: hdr,
}
// If there's no done channel (no deadline or cancellation
// from the caller possible), at least set some (long)
// timeout here. This will make sure we don't block forever
// and leak a goroutine if the connection stops replying
// after the TCP connect.
connectCtx := ctx
if ctx.Done() == nil {
newCtx, cancel := context.WithTimeout(ctx, 1*time.Minute)
defer cancel()
connectCtx = newCtx
}
didReadResponse := make(chan struct{}) // closed after CONNECT write+read is done or fails
var (
resp *Response
err error // write or read error
)
// Write the CONNECT request & read the response.
go func() {
defer close(didReadResponse)
err = connectReq.Write(conn)
if err != nil {
return
}
// Okay to use and discard buffered reader here, because
// TLS server will not speak until spoken to.
br := bufio.NewReader(conn)
resp, err = ReadResponse(br, connectReq)
}()
select {
case <-connectCtx.Done():
conn.Close()
<-didReadResponse
return nil, connectCtx.Err()
case <-didReadResponse:
// resp or err now set
}
if err != nil {
conn.Close()
return nil, err
}
if t.OnProxyConnectResponse != nil {
err = t.OnProxyConnectResponse(ctx, cm.proxyURL, connectReq, resp)
if err != nil {
return nil, err
}
}
if resp.StatusCode != 200 {
_, text, ok := strings.Cut(resp.Status, " ")
conn.Close()
if !ok {
return nil, errors.New("unknown status code")
}
return nil, errors.New(text)
}
}
...
}
- HTTP/HTTPS 代理:在 CONNECT 方法引入前,主要通过简单的 HTTP 请求转发来工作的,这种方式仅适用于HTTP协议,HTTP/1.1 中引入 CONNECT 方法后,改成代理服务器与目标服务器之间建立一个双向的 TCP 连接(类似 SOCKS5 的 CONNECT 请求),常用于 HTTPS 等需要加密的通信,实现 HTTP/HTTPS 代理时需要注意处理这两种情况。
- SOCKS5 代理:二进制协议,除了 HTTP/HTTPS 流量外,还可以支持 TCP/UDP 流量,请求阶段支持三种命令:
- 0x01,CONNECT 请求,客户端请求代理服务器通过 TCP 连接到目标服务器。最常用的命令,适用于 HTTP/HTTPS 请求和其他基于 TCP 的协议。
- 0x02,BIND 请求,在使用 CONNECT 请求建立完主连接后, 才使用 BIND 请求建立次连接,主要用于服务器端的应用程序,要求代理服务器绑定一个 IP 地址和端口,等待远程服务器主动连接,常用于 FTP 的主动模式。
- 0x03,UDP 转发,用于处理 UDP 流量,例如 DNS 查询、在线游戏和视频流等需要低延迟传输的应用程序。
常见的科学上网工具如 shadowsocks、v2ray 等,实际上将标准的代理服务器拆成了两部分,客户端部分运行在本地,服务端部分运行在远端服务器,两者之间使用自定义协议传输数据,它们的功能大同小异,常见如下:
- 转运代理协议流量:用户访问目标服务器的 HTTP/HTTPS 及 SOCKS5 流量会转化为代理工具的自定义格式(或者原封不动)搬运到远端服务器上,在远端服务器上连接到目标服务器获取响应,最后原路回传响应给本地用户
- 优化网络连接:本地解析DNS、复用底层连接、使用不同的传输协议突破限制等手段,都能加快我们访问目标服务器的速度
- 加密:从最简单的 AES 到最常用的 TLS 都可以起到加密数据的作用
- 混淆:对抗流量检测的玄学手段,我默认是它是不起作用的,无论使用什么手段,只要流量大了就容易上名单
- 请求分流:部分工具内置了分流功能,可以根据代理协议中的目标服务器地址,将请求分发到不同的代理服务器中转