深入理解HTTP代理:从理论到实践

488 阅读14分钟

本文主要分成三部分逐步深入,涉及比较多网络方面的理论知识和开发实践:

  • 第一部分:阐述HTTP代理的应用场景和基础理论知识。
  • 第二部分:介绍笔者项目中,使用本地代理服务来代理WebView流量,实现在外网也能打开内网应用的案例。
  • 第三部分:介绍Chromium中关于代理模块的一些源码实现。

一、HTTP代理的知识

1.1 代理服务器的应用场景

代理服务器的使用场景有很多,比如:

  1. 在公司或学校网络中,可能需要通过代理服务器才能访问Internet。
  2. 为了保护隐私,用户可能会使用代理服务器来隐藏自己的IP地址。
  3. 为了绕过地理限制,用户可能会使用位于特定国家/地区的代理服务器来访问某些网站或服务。
  4. 开发者可能会使用代理服务器来调试HTTP请求和响应。

1.2 普通代理和隧道代理

普通代理和隧道代理都是网络代理的一种形式,它们在处理客户端请求和数据传输方面有一些相同点和不同点。以下是对这两种代理的分别阐述。

1.2.1 普通代理

1.2.1.1 定义

普通代理,又称为正向代理,位于客户端和目标服务器之间。客户端将请求发送到代理服务器,代理服务器再将请求转发到目标服务器。目标服务器返回的响应同样经过代理服务器再返回给客户端。

来自《HTTP 权威指南》的定义是:

HTTP 客户端向代理发送请求报文,代理服务器需要正确地处理请求和连接(例如正确处理 Connection: keep-alive),同时向服务器发送请求,并将收到的响应转发给客户端。

web_proxy.png.webp

1.2.1.2 特点

普通代理的主要特点:

  1. 代理服务器可以修改客户端的请求和目标服务器的响应,例如添加、删除或修改HTTP头部。
  2. 代理服务器可以缓存目标服务器的响应,以提高访问速度和降低网络带宽消耗。
  3. 代理服务器可以对HTTP请求进行过滤和审计,实现访问控制和安全策略。

1.2.2 隧道代理

1.2.2.1 定义

隧道代理是一种特殊类型的代理服务器,它在客户端和目标服务器之间建立一个透明的TCP隧道。客户端通过隧道与目标服务器建立直接的TCP连接,代理服务器不会修改或解析传输的数据。

来自《HTTP 权威指南》的定义是:

HTTP 客户端通过 CONNECT 方法请求隧道代理创建一条到达任意目的服务器和端口的 TCP 连接,并对客户端和服务器之间的后继数据进行盲转发。

web_tunnel.png.webp

1.2.2.2 特点

隧道代理的主要特点:

  1. 代理服务器不会修改或解析通过隧道传输的数据,只负责传输数据包。
  2. 隧道代理通常用于建立安全连接(如SSL/TLS),在此情况下,代理服务器无法查看或修改加密的数据。
  3. 隧道代理可以穿越防火墙和NAT设备,访问内网或受限的网络资源。

1.2.3 普通代理和隧道代理异同

image.png

1.3 代理服务器认证过程

当客户端通过代理服务器发起请求,而该代理服务器需要认证时,会发生以下过程:

sequenceDiagram
    participant A as 客户端
    participant ProxyServer
    A->>ProxyServer: 发送请求
    ProxyServer-->>A: 返回407响应
    A->>A: 从Proxy-Authenticate头部读取认证信息
    A->>A: 选择认证方法
    A->>A: 获取认证凭据
    A->>ProxyServer: 重新发送请求,将认证凭据添加到Proxy-Authorization头部
    ProxyServer-->>A: 返回200 OK或407响应
    A->>A: 处理认证结果

1.4 代理连接与直接连接的区别

客户端向代理服务器发送流量与直接发送到目标服务器的过程有一些关键区别:

区别点代理连接直接连接
建立连接基于代理服务器的IP和端口基于目标服务器的IP和端口
发送请求将HTTP请求发送到代理服务器,URL使用完整路径将HTTP请求发送到目标服务器,URL使用相对路径
处理响应从代理服务器接收响应,代理服务器可能会修改响应头从目标服务器直接接收HTTP响应
安全连接通过与代理服务器建立TCP隧道,在隧道上建立SSL/TLS连接发送HTTP请求通过与目标服务器建立SSL/TLS连接发送HTTP请求
认证处理代理服务器的407 Proxy Authentication Required响应处理目标服务器的401 Unauthorized响应

二、如何在Android中建立WebView的本地代理

2.1 案例背景

笔者所在的项目中,一个使用网页代理的应用场景是:因为有一些页面是内网应用,在移动网络下无法访问,因此需要将内网应用的请求转发给内网的代理网关,其他的请求则可以把直接发送到外网。

2.2 思路分析

我们的解决方案是建立一个App侧的本地代理服务,将WebView的流量都转发给本地代理服务处理,由本地代理服务决定是通过代理连接发送请求,还是直接发送请求。

为什么要建立客户端侧的本地代理服务呢?能否直接把WebView的代理域名设置成真实的网关接入机域名呢?

理论上是可以的,但是建立客户端侧的本地代理服务有一些优势:

  • 本地代理服务内部是有很多业务逻辑的(如就近接入、判断走vpn隧道还是直连、判断是否需要通过特定的接入节点访问资源等)。如果去掉本地代理服务的话,各个端就需要自己实现这部分的逻辑。
  • 本地代理服务除了代理了WebView的流量,还代理了CGI的流量(通过给libcurl设置proxy)。这两种业务形式差别也很大,通过本地代理服务能够收拢到统一的地方处理两者的流量。
  • 客户端很多都是直接给浏览器设置proxy,没法往浏览器内核里嵌入业务逻辑。
  • 因为使用C++实现,能够给android、iOS、PC各端复用。

基于以上原因,我们在客户端内部建立了一个本地代理服务,本质上这是一个在客户端内部的HTTP SERVER

2.3 解决方案一览

于是我们将WebView的代理地址设置为本地地址127.0.0.1,然后初始化一个本地HTTP SERVER来代理WebView的请求。对于本地代理服务,我们使用了基于libevent的C++实现,这样android、iOS和pc端都可以复用这个代理服务。

下面是整体方案实现的时序图:

sequenceDiagram
Participant A as App
Participant P as 本地代理服务(位于APP侧)
Participant S as 代理网关服务(位于接入机)

A->>P: Send c_req
alt 请求是http
    P->>S: 本地代理服务发起连接
    S-->>P: Connection Established 200 | 407
    P->>S: 转发http请求
    S->>P: 转发请求完成
    P-->>A: 发送响应
else 请求是https
    P->>S: 转发connect请求
    S-->>P: Connection Established 200
    S-->>A: 发送响应
end

2.4 设置WebView的代理地址和代理端口

首先,需要设置WebView的代理地址和代理端口。因为我们是使用本地的代理服务,所以host设置为127.0.0.1,端口则是随机选择一个空闲的端口号。

下面的实现主要做了这些事情:

  1. 检查WebView是否支持代理覆盖功能。
  2. 创建ProxyConfig.Builder实例并添加代理规则。
  3. 检查WebView是否支持反向代理覆盖功能并添加例外规则。setReverseBypassEnabledtrue时,通过addBypassRule添加的地址将被视为黑名单。也就是在列表中的地址将通过代理服务器访问,而不在列表中的地址将直接访问,不经过代理服务器。
  4. 将直接连接添加到代理规则中。
  5. 应用代理配置并设置回调函数
// 检查WebView是否支持代理覆盖功能
if (ReflecterHelper.invokeStaticMethod("androidx.webkit.WebViewFeature", "isFeatureSupported", arrayOf(WebViewFeature.PROXY_OVERRIDE)) as? Boolean != true) {
    return
}

// 创建ProxyConfig.Builder实例并添加代理规则
val builder = ReflecterHelper.newInstance("androidx.webkit.ProxyConfig\$Builder")
ReflecterHelper.invokeMethod(builder, "addProxyRule", arrayOf(proxy))

// 检查WebView是否支持反向代理覆盖功能并添加例外规则
if (ReflecterHelper.invokeStaticMethod("androidx.webkit.WebViewFeature", "isFeatureSupported", arrayOf(WebViewFeature.PROXY_OVERRIDE_REVERSE_BYPASS)) as? Boolean == true) {
    urlsToProxy?.forEach {
        if (!TextUtils.isEmpty(it)) {
            ReflecterHelper.invokeMethod(builder, "addBypassRule", arrayOf(it))
            ReflecterHelper.invokeMethod(builder, "setReverseBypassEnabled", arrayOf(true))
        }
    }
}

// 将直接连接添加到代理规则中
ReflecterHelper.invokeMethod(builder, "addDirect")

// 应用代理配置并设置回调函数
val controller = ReflecterHelper.invokeStaticMethod("androidx.webkit.ProxyController", "getInstance")
val config = ReflecterHelper.invokeMethod(builder, "build")
ReflecterHelper.invokeMethod(controller, "setProxyOverride", arrayOf(config.javaClass, Executor::class.java, Runnable::class.java), arrayOf(config, Executor { command -> command.run() }, callback))

2.5 在APP侧建立本地代理服务

2.5.1 实现思路

本地代理服务负责监听本地地址的流量,如果遇到需要转发到代理网关的url,则通过本地代理服务转发请求;否则就直接发送请求。这里的完整实现细节比较复杂,本文只能展示一小部分。

2.5.2 代码实现

下面展示了如何在APP侧使用libevent初始化一个HTTP SERVER:

  • 创建一个新的evhttp实例,设置允许的HTTP方法,并尝试在指定IP地址上绑定一个端口。
  • 如果绑定失败,将尝试10次随机生成一个端口并绑定。
  • 成功绑定端口后,函数将显示监听的套接字信息,并返回成功。
*http_server = evhttp_new(base);
evhttp_set_allowed_methods(*http_server, /*...*/);

for (int i = 0; i < 10; i++) {
    if (port == 0) {
        port = (rand() % 20000) + 10000;
    }

    handle = evhttp_bind_socket_with_handle(*http_server, PROXY_IP_ADDRESS, port);
    if (!handle) {
        port = 0;
        continue;
    }
    break;
}

if (port == 0) {
    // 返回错误
}

// 成功返回

2.6 APP侧的本地代理服务如何转发流量

2.6.1 实现思路

如果是https的请求,本地代理服务首先转发CONNECT请求,获得网关接入机的成功应答后,建立网络隧道,再透明转发WebView和网关接入机之间的流量,后续的交互流量都是SSL加密通信,本地代理服务也无法解密读取。网关接入机使用Nginx,本身就具备了建立代理隧道的能力。

2.6.2 代码实现

下面的实现用于转发CONNECT请求到网关接入机,主要逻辑是:

  • 获取连接的主机和客户端连接。
  • 获取客户端的 bufferevent 和代理连接,并创建新的代理请求。
  • 复制请求头和请求体,并创建代理请求。如果是 CGI 线程,为了性能优化,先回复客户端 200 OK。
// 获取连接的主机
string host = ctx->connect_host;

// 获取客户端连接和bufferevent
ctx->client_conn = evhttp_request_get_connection(ctx->client_req);
ctx->client_bufev = evhttp_connection_get_bufferevent(ctx->client_conn);

// 获取代理连接
ctx->proxy_conn = get_proxy_connection(ctx, ctx->scheme, ctx->ip, ctx->port);

// 创建新的代理请求,设置错误回调
ctx->proxy_req = evhttp_request_new(connect_request_done, ctx);
evhttp_request_set_error_cb(ctx->proxy_req, http_request_error);

// 复制请求头和请求体
http_header_copy(ctx, ctx->client_req, ctx->proxy_req, CLIENT_TO_GATEWAY);
evbuffer_add_buffer_reference(evhttp_request_get_output_buffer(ctx->proxy_req), 
    evhttp_request_get_input_buffer(ctx->client_req));

// 创建代理请求
evhttp_make_request(ctx->proxy_conn, ctx->proxy_req,
    evhttp_request_get_command(ctx->client_req), host.c_str());

// 添加连接到全局上下文
ctx->gCtx->AddConn(ctx->client_bufev, ctx->client_conn);
ctx->gCtx->AddConn(ctx->proxy_bufev, ctx->proxy_conn);

// 如果是CGI线程,回复客户端200 OK,设置回调函数,禁用读取操作
if (ctx->gCtx->IsCgiThread()) {
    evhttp_send_reply(ctx->client_req, 200, "Connection Established", NULL);
    bufferevent_setcb(ctx->client_bufev, readcb, NULL, eventcb, ctx);
    bufferevent_disable(ctx->client_bufev, EV_READ);
}

2.6.3 libevent的接口说明

上面的代码实现中,涉及了很多libevent的接口,以下是这些接口按作用归类的列表及简要说明:

连接管理:

  • evhttp_request_get_connection:获取与指定请求关联的evhttp_connection对象。
  • evhttp_connection_get_bufferevent:获取与指定evhttp_connection关联的bufferevent对象。

请求创建与发送:

  • evhttp_request_new:创建一个新的evhttp_request对象,并设置请求完成时的回调函数。
  • evhttp_request_set_error_cb:为指定的evhttp_request设置错误回调函数。
  • evbuffer_add_buffer_reference:将一个evbuffer的内容添加到另一个evbuffer中,同时保持对原始缓冲区的引用。
  • evhttp_make_request:将指定的evhttp_requestevhttp_connection关联,并发送请求。

响应处理:

  • evhttp_send_reply:向客户端发送HTTP响应。

事件回调与控制:

  • bufferevent_setcb:为指定的bufferevent设置回调函数。
  • bufferevent_disable:禁用指定的bufferevent上的某些事件(在此例中禁用读取操作)。

这些接口涵盖了创建和管理HTTP请求、连接、缓冲区和事件回调的主要功能。通过使用这些接口,可以实现代理服务器的核心功能,如转发请求、处理响应和管理连接。

三、Chromium如何实现代理连接

在第二部分中,我们通过反射Webview的内部接口,给Webview内核设置了代理。Chromium是如何把网页的流量导到我们设置的代理服务器地址呢?本节就来看下这个问题。

3.1 Chromium将流量导向代理服务器的过程

当一个HTTP请求发起时,Chromium首先需要确定是否使用代理服务器。以下是Chromium将流量导向代理服务器的主要步骤:

  1. 获取代理配置:Chromium通过ProxyConfigService获取代理配置。这些配置可能来自用户设置或操作系统设置。ProxyConfigService会返回一个ProxyConfig实例,其中包含代理规则和例外列表。
  2. 解析代理规则ProxyService根据ProxyConfig中的代理规则为HTTP请求选择合适的代理服务器。这个过程可能涉及解析PAC文件(通过ProxyResolverV8)或者使用固定的代理规则(通过ProxyResolverFixed)。
  3. 选择代理服务器ProxyService会根据HTTP请求的URL和代理规则,为该请求选择一个合适的代理服务器。如果没有合适的代理服务器,或者配置了直接连接(DIRECT),那么该请求将直接发送到目标服务器。
  4. 建立连接:Chromium使用ClientSocketPoolManager来管理网络连接。当需要使用代理服务器时,ClientSocketPoolManager会为代理服务器创建一个新的ClientSocketHandle。这个ClientSocketHandle包含了代理服务器的IP地址和端口。
  5. 发送请求:Chromium将HTTP请求发送到代理服务器。如果代理服务器需要认证,Chromium会处理认证过程。对于HTTP代理,Chromium会在HTTP请求头中添加Proxy-Connection字段。对于SOCKS代理,Chromium会遵循SOCKS协议发送请求。
  6. 接收响应:代理服务器将请求转发到目标服务器,并将目标服务器的响应返回给Chromium。Chromium会处理响应,解析页面内容并呈现给用户。

通过以上步骤,Chromium可以将流量导向代理服务器,实现在不同网络环境下的访问控制、隐私保护等功能。

3.2 Chromium中的代理服务器源码文件

Chromium中的net/proxy目录下包含了与代理服务器相关的源码文件。以下是一些主要文件及其对应的功能:

  1. proxy_config.cc / proxy_config.hProxyConfig类表示代理配置,包括代理规则和例外列表。这些配置可以来自用户设置或操作系统设置。
  2. proxy_config_service.cc / proxy_config_service.hProxyConfigService类是一个抽象类,用于获取当前的ProxyConfig。具体的实现可能会依赖于操作系统或用户设置。
  3. proxy_info.cc / proxy_info.hProxyInfo类包含了为特定URL选择的代理服务器信息。在发起HTTP请求时,ProxyService会使用ProxyInfo来确定使用哪个代理服务器。
  4. proxy_list.cc / proxy_list.hProxyList类表示一组备选的代理服务器。在某些情况下,可能有多个代理服务器可供选择,ProxyList提供了从中选择一个可用代理的功能。
  5. proxy_service.cc / proxy_service.hProxyService类负责根据代理配置为HTTP请求选择合适的代理服务器。它使用ProxyConfigService来获取代理配置,并将其应用到HTTP请求。
  6. proxy_server.cc / proxy_server.hProxyServer类表示一个具体的代理服务器,包括代理协议(如HTTP、SOCKS4、SOCKS5等)、主机名和端口。
  7. proxy_resolver.cc / proxy_resolver.hProxyResolver类是一个抽象类,用于解析代理规则。具体的实现可能包括PAC文件解析(proxy_resolver_v8.cc / proxy_resolver_v8.h)或者固定的代理规则(proxy_resolver_fixed.cc / proxy_resolver_fixed.h)。

四、总结

本文围绕网络代理的相关内容,先阐述理论基础,然后给出笔者项目中一个WebView代理的具体案例,最后深入到Chromium源码中的代理实现,由浅入深地展示了网络代理的理论和应用。希望可以帮助读者在实际场景中更好地利用代理服务器,实现相关的需求。