Atlantis
GitHub Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

1. 代理协议

1. 前言

这里讨论的代理协议是用于隐藏访问者的正向代理,更准确说是 HTTP 代理与 SOCKS 代理。印象里最早是在 Chrome 上安装红杏来提供代理访问谷歌,当时尚不清楚它的工作机制,后来在 Ubuntu 上遇到网络问题,开始使用 privoxy 转换 SOCKS5 为 HTTP 代理时,开始接触到这几个环境变量:HTTP_PROXY、HTTPS_PROXY、NO_PROXY

这里会简单讨论下常用的 HTTP/HTTPS 及 SOCKS5 代理协议的工作机制。

2. 谁在使用代理

我最早接触的代理工具是 Proxy SwitchySharp 以及后来的 SwitchyOmega,它们都以插件的形式运行在 Chrome 上,我一度以为所有的网络请求都是经过插件转发的,直到后来看到了一个 issue:Feature proposal: SOCKS5 over TLS #1838。提出 issue 的人希望 SwitchyOmega 能支持 TLS 加密的 SOCKS5 代理,但有一个 Contributor 回答:

  1. SwitchyOmega 只是一款代理服务器切换软件,和翻墙无关。
  2. 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 及其他命令行工具,可以通过代理服务器访问了。

所以说,代理服务器的使用者是发起网络连接的应用程序。

3. 应用程序如何使用代理

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_PROXYHTTPS_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)
		}
	}
...
}
  1. HTTP/HTTPS 代理:在 CONNECT 方法引入前,主要通过简单的 HTTP 请求转发来工作的,这种方式仅适用于HTTP协议,HTTP/1.1 中引入 CONNECT 方法后,改成代理服务器与目标服务器之间建立一个双向的 TCP 连接(类似 SOCKS5 的 CONNECT 请求),常用于 HTTPS 等需要加密的通信,实现 HTTP/HTTPS 代理时需要注意处理这两种情况。
  2. SOCKS5 代理:二进制协议,除了 HTTP/HTTPS 流量外,还可以支持 TCP/UDP 流量,请求阶段支持三种命令:
    1. 0x01,CONNECT 请求,客户端请求代理服务器通过 TCP 连接到目标服务器。最常用的命令,适用于 HTTP/HTTPS 请求和其他基于 TCP 的协议。
    2. 0x02,BIND 请求,在使用 CONNECT 请求建立完主连接后, 才使用 BIND 请求建立次连接,主要用于服务器端的应用程序,要求代理服务器绑定一个 IP 地址和端口,等待远程服务器主动连接,常用于 FTP 的主动模式。
    3. 0x03,UDP 转发,用于处理 UDP 流量,例如 DNS 查询、在线游戏和视频流等需要低延迟传输的应用程序。

4. 科学上网工具发挥了什么作用

常见的科学上网工具如 shadowsocks、v2ray 等,实际上将标准的代理服务器拆成了两部分,客户端部分运行在本地,服务端部分运行在远端服务器,两者之间使用自定义协议传输数据,它们的功能大同小异,常见如下:

  1. 转运代理协议流量:用户访问目标服务器的 HTTP/HTTPS 及 SOCKS5 流量会转化为代理工具的自定义格式(或者原封不动)搬运到远端服务器上,在远端服务器上连接到目标服务器获取响应,最后原路回传响应给本地用户
  2. 优化网络连接:本地解析DNS、复用底层连接、使用不同的传输协议突破限制等手段,都能加快我们访问目标服务器的速度
  3. 加密:从最简单的 AES 到最常用的 TLS 都可以起到加密数据的作用
  4. 混淆:对抗流量检测的玄学手段,我默认是它是不起作用的,无论使用什么手段,只要流量大了就容易上名单
  5. 请求分流:部分工具内置了分流功能,可以根据代理协议中的目标服务器地址,将请求分发到不同的代理服务器中转