在Chromium普惠生态下PAC脚本服务器面临的挑战

随着微软发布基于 Chromium 的 Edge 以及 Edge WebView2 Runtime,Chromium 在 Windows 桌面上扮演着越来越重要的角色。在日常工作中,当用户打开了 Outlook 收发邮件、Teams 进行会议以及 Edge 浏览网页时,操作系统上就已经同时运行着 3 个 Chromium 的实例了。在企业环境中如果配置了系统使用 PAC (代理自动配置)脚本,在当今的这种 Chromium 普惠生态环境中可能会对部署 PAC 脚本的服务器带来较大的请求压力。

PAC 脚本简介

PAC 脚本,即代理自动配置( Proxy Auto-Configuration )脚本,是一种 JavaScript 文件,用于指导浏览器根据访问的网站 URL 决定使用哪个代理服务器或者直接连接。在企业环境中,通过利用 PAC 脚本动态指定网络流量的代理规则,可以有效地管理和优化网络访问策略,同时增强网络的安全和效率。

function FindProxyForURL(url, host) {
    // 判断如果是内网地址,则直接连接
    if (shExpMatch(host, '*.internal.example.com') || isPlainHostName(host)) {
        return 'DIRECT';
    }
    // 其他情况,使用指定的代理服务器
    return 'PROXY proxy.example.com:8080';
}

当浏览器配置使用 PAC 脚本时,每次尝试访问任何 URL 之前,浏览器都会执行这个脚本来决定如何连接到目标网址。具体来说,浏览器会调用 PAC 文件中定义的 FindProxyForURL 函数,将当前访问的 URL 和主机名作为参数传递给该函数。基于这个函数返回的结果,浏览器决定是直接访问目标网站(DIRECT),还是通过指定的代理服务器(如 PROXY proxy.example.com:8080)进行访问。

在上面的样例脚本中,shExpMatch 函数用于检查目标主机名是否符合特定的模式(在这个例子中是内网域名 *.internal.example.com),而 isPlainHostName 函数则用来判断是否是一个没有明确指定域的简单主机名,通常用来识别局域网内的资源。如果这两个条件之一满足,脚本将指示浏览器直接连接目标地址。否则,浏览器将通过 proxy.example.com 上的 8080 端口指定的代理服务器进行连接。这样的配置确保了内网流量不会经过外部代理,而外网访问则通过企业代理服务器进行,既优化了访问速度,也保障了网络流量的安全管理。

在 Windows 中如何配置 PAC 脚本

在 Windows 10/11 中我们可以通过 Settings -> Network & internet -> Proxy 页面中启用 Use setup script 并在下放 Script address 中填入 PAC 脚本地址。

或是通过传统的 Control Pannel -> Internet Options -> Connections -> LAN settings 页面中启用 Use automatic configuration script 并在下方 Address 中填入 PAC 脚本地址。

谁会获取 Windows 中配置的 PAC 脚本设置?

在 Windows 上配置的代理设置(是否使用自动检测代理,还是使用 PAC 脚本,还是使用静态代理服务器)是存放在注册表路径下的,具体位置是:HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings\Connections 下的 DefaultConnectionSettings 二进制键值。

所以任何应用程序都是有权限获取当前 Windows 的代理设置的,不过一般不直接通过读取注册表来获取设置,而是通过 WinHttp API: WinHttpGetIEProxyConfigForCurrentUser

即便 Chromium 拥有自己的网络堆栈来负责处理各类网络协议包括 HTTP 网络访问,它在 Windows 上依然使用了 WinHttpGetIEProxyConfigForCurrentUser 来获取当前用户的代理设置。

摘自 net/proxy_resolution/win/proxy_config_service_win.h 中的部分代码:

...
// Implementation of ProxyConfigService that retrieves the system proxy
// settings.
//
// It works by calling WinHttpGetIEProxyConfigForCurrentUser() to fetch the
// Internet Explorer proxy settings.
//
// We use two different strategies to notice when the configuration has
// changed:
//
// (1) Watch the internet explorer settings registry keys for changes. When
//     one of the registry keys pertaining to proxy settings has changed, we
//     call WinHttpGetIEProxyConfigForCurrentUser() again to read the
//     configuration's new value.
//
// (2) Do regular polling every 10 seconds during network activity to see if
//     WinHttpGetIEProxyConfigForCurrentUser() returns something different.
//
// Ideally strategy (1) should be sufficient to pick up all of the changes.
// However we still do the regular polling as a precaution in case the
// implementation details of  WinHttpGetIEProxyConfigForCurrentUser() ever
// change, or in case we got it wrong (and are not checking all possible
// registry dependencies).
...

谁会去下载和使用 PAC 脚本?

既然任何应用程序都能获取到用户配置的 PAC 脚本设置,那么应用程序当然可以选择遵循用户的意愿使用 PAC 脚本来进行网络通信。所以理论上所有需要网络连接的应用程序,一般都会去下载使用 PAC 脚本。在 Windows 上会去下载使用 PAC 脚本的应用可以分为两大类:

基于 Chromium 的应用程序

第一类是基于 Chromium 内核开发的应用,包含了:

  1. 基于 Chromium 的浏览器,比如:Chrome 和 Microsoft Edge
  2. 基于 Edge WebView2 Runtime 开发的桌面应用,比如:Outlook 的插件系统、新版 Teams、PowerBI、Quick Assist、Clipchamp 等
  3. 基于 Chromium Embedded Framework 开发的桌面应用,比如:Adobe Acrobat、Spotify 等
  4. 基于 Electron 开发的桌面应用,比如:VS Code、Atom、QQ、Slack 等

下面是这类基于 Chromium 的应用在下载 PAC 脚本时所携带的 User-agent 的样例,注意 Edge WebView2 Runtime、CEF 以及 Electron 均支持开发自定义发出去的 User-agent。

Application User-agent
Edge / Apps using Edge WebView2 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0
Chrome Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
VS Code Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Code/1.87.0 Chrome/118.0.5993.159 Electron/27.3.2 Safari/537.36
Adobe Acrobat Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ReaderServices/23.8.20555 Chrome/105.0.0.0 Safari/537.36
QQ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) QQ/9.9.7-21804 Chrome/120.0.6099.56 Electron/28.0.0 Safari/537.36

WinHttp AutoProxy Service

第二类则是 Windows 内置的一个系统服务:WinHTTP Web Proxy Auto-Discovery Service。在 Windows 上使用了 WinINetWinHttp 作为网络栈的应用在请求 URL 时都会调用该服务来统一执行 PAC 脚本以确定用什么形式访问该 URL。这类应用包括了:

  1. Internet Explorer 浏览器或是内嵌了 WebBrowser Control 的应用( WebBrowser Control 的内核也是 IE )
  2. 任何使用了 WinINet 或 WinHttp 库的应用,比如:Word、Outlook、OneDrive 等
  3. 任何使用了 System.Net.HttpSystem.Net.HttpWebRequest 作为网络通信的 .NET 应用

WinHttp AutoProxy Service 下载 PAC 脚本时所携带的 User-agent 为:WinHttp-Autoproxy-Service/5.1

处理 HTTP 请求的过程包含了众多细节,尤其是当涉及到代理配置时。在实际开发中,由于执行 PAC 脚本需要 JavaScript 引擎,这个过程变得更加复杂。因此,大多数开发者和应用程序都倾向于使用已经封装好的类库,如 WinHttp, WinINet, 或 Chromium,来发起 HTTP 请求。这些类库的一个主要优势是它们能够自动处理 PAC 脚本,无需开发者深入了解背后的复杂逻辑。

当然,技术上讲,开发者完全有可能从头开始实现一个 HTTP 类库,包括下载和处理 PAC 脚本的逻辑。然而,鉴于这涉及到复杂的网络编程知识,以及额外的工作量(特别是实现和维护一个 JavaScript 引擎来执行 PAC 脚本),这种做法在实际中非常少见。大多数情况下,为了效率和稳定性,重用现有的、经过充分测试的网络类库是更加合理的选择。

Chromium 下载 PAC 脚本的行为逻辑

在通过本地反复测试后,总结出的 Chromium 下载 PAC 脚本的行为如下:

  1. 每启动一个 Chromium 实例,它会发送 2~3 个 PAC 脚本下载请求并将 PAC 脚本缓存下来,如果 Chromium 持续运行,约 12 小时后会再次下载 PAC 脚本。
  2. 每个 Chromium 实例运行时会监测 Windows 代理设置的变化,如果 PAC 脚本地址发生了改变,Chromium 会再次发送 2~3 个 PAC 脚本下载请求以获取最新的 PAC 脚本。
  3. 如果 PAC 脚本存在 JavaScript 语法错误,Chromium 会每隔 8 秒重新发送 2~3 个 PAC 脚本下载请求。
  4. 如果发生了网络变化(切换网络、IP 地址变更),Chromium 会重新发送 2~3 个 PAC 脚本下载请求。

根据 Proxy support in Chrome 中针对下载 PAC 脚本的说明,我们可以知道:

  1. Chromium 下载 PAC 脚本是不使用任何 HTTP 缓存机制的(不管是协商缓存还是强缓存)
  2. PAC 脚本的下载不支持 HTTP 认证(自动认证可能可以用,但是绝不会弹框)
  3. 获取 PAC 脚本的超时时间为 30 秒。
  4. 在下载 PAC 脚本失败时,Chromium 将以下面的间隔来尝试重新下载 PAC 脚本:
    • 第一次获取失败的 8 秒后
    • 之后的 32 秒
    • 之后的 2 分钟
    • 此后每 4 小时

Chromium 普惠生态对部署 PAC 脚本服务器的影响

在了解 Chromium 下载 PAC 脚本的行为逻辑后,我们可能觉得每打开一次发起 2~3 个下载请求不是什么大问题。不过这在现今的 Chromium 普惠生态中,可能会对部署 PAC 脚本的服务器带来一些潜在的压力。PAC 脚本部署常见于企业环境,一个企业往往管理着成百上千台客户机。

现在我们假设客户端上运行着下面这些常用的应用:

  1. Outlook
  2. Teams
  3. Edge
  4. Adobe Reader

也就是说当前系统上有 4 个 Chromium 的实例正在运行,这个时候如果 IT 部门通过组策略或注册表推送了新的 PAC 脚本地址,这台机器上所有的 Chromium 都会监测到配置更改,并发出 8~12 个下载请求以获取更新后的 PAC 脚本地址。假使这次 PAC 脚本更改影响范围是 5000 台 PC,那么部署 PAC 脚本的服务器瞬时承担的请求数量可能会达到 50000 以上,服务器可能处理不了那么巨量的请求,于是大批请求在等待 30 秒后超时,接连导致 Chromium 在 8 秒后会再次发起请求去下载,所以在接下来的几分钟内服服务器不断承受巨大的流量压力。

由于 Chromium 下载 PAC 脚本是不使用 HTTP 缓存的,这个问题看似没有很好的解决方案,唯一能做的就是增加服务器的性能或者使用负载均衡把流量分摊至多台服务器。不过由于 PAC 脚本下载只发生在应用启动以及 PAC 地址变更时,之后可能很长时间这些部署 PAC 脚本的服务器都会处于空闲状态,所以只为了应对每次脚本地址更改造成的流量压力而增加硬件上的投资显得又有点浪费。

WinHttp AutoProxy Service 是一个不错的设计

Chromium 开发之初应该是没有想到一台机器上会运行着如此多 Chromium 应用的情况,每次打开 Chromium 或者当 PAC 脚本变化时去服务器下载 PAC 脚本显得理所当然。

而更专注于企业解决方案的微软可能更早地意识到了 PAC 脚本下载在企业环境中的痛点。如果任由每个使用 WinHttp 或 WinINet 的应用自己单独去下载 PAC 脚本,那显然是不可接受的。

早期需要网络访问的应用只有 IE 浏览器,之后越来越多的应用需要连网了,于是诞生了 WinHttpGetIEProxyConfigForCurrentUser 这样的 API 给各类需要网络访问的应用获取用户的浏览器代理设置以便其使用正确的代理来访问网络,之后 IE 代理设置就演变为了 Windows 系统级别的代理设置。那么当用户配置了 PAC 脚本或者 WPAD 时,我们显然是不能在一个 HTTP 库中加入一个 JavaScript 引擎来解析代理脚本,同时也不希望每个应用单独去下载 PAC 脚本,一个更好的做法就是部署一个系统服务来专门处理 PAC 脚本的下载解析执行以及 WPAD 的探测,于是就诞生了 WinHttp AutoProxy Service。刚才提到的 WPAD 是另一个话题了,这篇文章不会深入探讨它,不过它的本质和 PAC 脚本一样的,只不过它的 URL 不需要显示指定而是根据 WPAD 探测规则由系统自行检测。

WinHttp 提供了 WinHttpGetProxyForUrl 函数和 WinHttpGetProxyForUrlEx 函数来向 WinHttp AutoProxy Service 查询访问某个 URL 需要使用的代理服务器信息,两者的主要区别在于 WinHttpGetProxyForUrl 是同步的,而 WinHttpGetProxyForUrlEx 提供了异步执行的能力。

WinHttp AutoProxy Service 下载 PAC 脚本的行为逻辑

在通过本地反复测试后,总结出的 WinHttp AutoProxy Service 下载 PAC 脚本的行为如下:

  1. WinHttp AutoProxy Service 进程启动时,它会发出一个下载 PAC 脚本的请求。

  2. 使 Windows 处于空闲待机状态一段时间后,通过 PAC 服务器日志可以发现 WinHttp AutoProxy Service 每半小时会下载一次 PAC 脚本。

  3. WinHttp AutoProxy Service 同样会监测 Windows 代理设置的变化,如果 PAC 脚本地址发生了改变,它会立刻下载 PAC 脚本。

  4. 应用程序甚至可以在调用 WinHttpGetProxyForUrl 函数时指定其 lpszAutoConfigUrl 参数为任意其它的 PAC 脚本地址使 WinHttp AutoProxy Service 下载指定的 PAC 脚本。

Chromium 可以使用 WinHttp AutoProxy Service 吗?

答案是可以的。

Edge / Chrome 浏览器使用 WinHttp AutoProxy Service

Edge 和 Chrome 在启动时都可以通过添加参数 --winhttp-proxy-resolver 以指定其使用当前操作系统的网络栈来解析代理服务器信息。

如果不想每次启动都添加该参数,我们也可以开启 Edge 的组策略设置: Use Windows proxy resolver 使 Edge 在 Windows 上默认使用 WinHttp AutoProxy Service 来进行代理解析。

Edge WebView2 使用 WinHttp AutoProxy Service

经过我测试,如果启动 Edge WebView2 进程时添加 --winhttp-proxy-resolver 参数,也是生效的。虽然 Edge WebView2 进程启动时的参数往往由应用代码来决定,好在我们可以通过 WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS 环境变量来指定额外的启动参数,这样我们就可以将 --winhttp-proxy-resolver 添加至每一个用到 Edge WebView2 的进程了。

Electron 和 CEF

至于基于 Electron 和 CEF 开发的应用,貌似没有对应的环境变量可以像 Edge WebView2 那样指定额外的启动参数,只能通过代码级别添加。

注意:https://crbug.com/1032820 中提到将来可能用 --use-system-proxy-resolver 来代替 --winhttp-proxy-resolver,以及当前 Chromium 使用 --winhttp-proxy-resolver 的一些局限性。