background blurbackground mobile blur

1/1/1970

我是怎么让 Foony 在代理后面正常运行的

嗨!我早就知道网页代理会给网站带来各种兼容性问题。不过,Foony 在代理里的兼容性一直都出了名的糟糕,要把这些代理相关的问题彻底搞定,其实挺棘手的。

这也不是那种“Foony 用了什么特别冷门的 API”导致的(虽然我们确实用了)。真正的原因是好几件事叠在一起:

  • 一些代理会在根本不该动的地方,疯狂改写字符串。
  • 一些代理把主站域名和“其他”域名(CDN、静态资源域名之类)当成两种东西来处理。
  • 还有一个残酷现实:有些代理就是撑不起现代 Web 应用,比如 HTTPS 处理不对、WebSockets 跑不通之类。

我们暂时还做不到在所有代理里都能跑,不过现在至少能在 croxyproxyproxyorb 里正常工作,这就是这次折腾的目标。

下面我会讲:到底哪里坏了、为什么会坏、以及真正有用的修补办法。


第一轮:明明合法,却会报错的 Three.js 着色器

表现出来的问题

用 croxyproxy 试的时候,我没法玩 8 Ball Pool,也没法玩 Foony 的其他的 three.js 游戏。 Three.js 一直报 shader 编译失败,错误信息大概是:

  • “Shader Error 1282 - VALIDATE_STATUS false”

这个报错几乎一点用都没有,基本就等于在说:“你的 shader 不合法,祝你好运。” 真棒。要是你有天好奇为什么 Foony 里每一种错误我都要做一条独特的错误信息,就是因为这个:这样才能精确定位问题,而不是只告诉你一句“代码坏了,自己想办法吧”。

可问题是:这些 three.js shader 明明合法,为什么还是会炸?到底哪儿不对?

真正的原因:代理把 layout(location = N) 搞坏了

Three.js 会生成带有 layout 限定符的 GLSL,比如:

layout(location = 0) in vec3 position;

有些代理会试图把页面里所有看起来像 JavaScript location API 的东西都改写,用的还是最原始的全局字符串替换。光在 JS 里这么干就已经很要命了,它们甚至还会跑到 shader 的源码字符串里去乱改。大概在它们眼里,做个 AST 解析实在太贵了吧。

结果 shader 源码就被改成了这样:

layout(__cpLocation = 0) in vec3 position;

这里的标识符必须location,换成别的就不是合法的 GLSL 了,编译器直接拒绝。(GLSL 里的 Layout 限定符)

从某种意义上说,这算是 Three.js 的“锅”,因为 shader 是 Three.js 动态生成的,我们在运行时再把它交给 WebGL。可真正的 bug 还是出在代理那种粗暴的改写策略上。

为什么我没有去“修理代理”

最直接的想法当然是:找到 croxyproxy 把 location 替换成的那个字符串 __cpLocation,然后再把它替换回 location

但问题是,不同的代理用的不一样的替换名。有的用 __cpLocation,有的用别的怪名字。要是我硬编码一个“把 __cpLocation 再替换回 location”的补丁,就会非常脆弱。

我需要的是:

  • 一个通用的修复方案(不依赖任何特定代理的变量名)。
  • 一个即使代理把我 JS 里的 location 这个单词也改掉了,还能继续工作的方案。

base64 小技巧:把 location 这个单词藏起来

如果代理会改写它看到的每一个 location 字面量,那最简单的办法就是:我干脆别直接写 location 就行了。很好办。

我之前在拆 RestedXP 指南保护系统的时候,用 Lua 见过类似的骚操作(如果我没记错的话,他们会把对 BNGetInfo 的调用做混淆,比如 _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F") 这样)。

这种套路在 JavaScript 里当然也能用。在 client/index.html 里,我在运行时解码了这么一段:

// Because these proxies try to replace every `location`, we use a base64 encoded string.
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location

这一行 atob() 会在代理已经把 HTML / JS 全部改写完之后才执行,所以它没法提前把这个字符串“改坏”。我把字符串拆成两半是为了再多躲一点检测,用 'atob' 纯粹是因为顺手,其实用 String.fromCharCode,或者像 window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] 这样十六进制逃逸,也都能达到类似效果。

被改坏的 shader 里,这一段的结构总是长一个样:

layout(<something> = <number>)

所以我就用一个通用的正则去匹配,然后把里面的 <something> 换成正确的标识符:

source.replace(/layout\s*\(\s*[^=)]+\s*=\s*(\d+)\s*\)/g, 'layout(' + locStr + ' = $1)');

WebGL 钩子:改写 shaderSource(WebGL1 + WebGL2)

因为 Three.js 最后是调用 gl.shaderSource(shader, source),所以我干脆把 shaderSource 本身打了个补丁:

const originalShaderSource = WebGLRenderingContext.prototype.shaderSource;

Object.defineProperty(WebGLRenderingContext.prototype, 'shaderSource', {
  value: function (shader, source) {
    return originalShaderSource.call(this, shader, fixCorruptedShaderSource(source));
  },
  writable: true,
  configurable: true,
});

如果有 WebGL2RenderingContext,我也对它做同样的处理。

补丁加上去之后,shader 编译错误就消失了。这个时候,在 croxyproxy 里已经能正常玩了,可 proxyorb 还是不行。为什么?!按理说不是应该一样吗?


第二轮:域名问题,以及为什么删掉 foony.io 一切都轻松多了

过去很长一段时间里,至少最近这一个月,Foony 一直在用两个域名:

  • foony.com:主站
  • foony.io:静态资源

这么做最开始的理由挺实际:静态资源放在一个没有 cookie 的域名上,请求这些文件时就不会每次都带上一堆 cookie header,节省了不少流量。这个优化确实不错,不过在 HTTP/2 时代没你想的那么关键,因为 HTTP/2 会用 HPACK 来压缩请求头,实际线上跑的字节会少很多。

在正常浏览环境里,这确实是一个合理的小优化。

可一旦放到各种网页代理后面,它立刻就变成了主要的出错来源。而 Foony 玩家又特别爱用代理,

代理会把“主站”和“其他站点”当成两种东西

很多代理的设计思路都是“帮你代理这一个页面 / 这一个域名”。主 HTML 能正常加载,它们能顺利插入自己的脚本、注册自家的 ServiceWorker,之类的。

可一旦应用开始从另一个源(比如 foony.io)拉静态资源,就会出现各种好玩的翻车场景:

  • ServiceWorker 拦截失败,比如:
    • “ServiceWorker intercepted the request and encountered an unexpected error”
    • “Loading failed for the module with source”
  • 代理内部依赖某些查询参数来工作(而这些参数又很容易在中途被不小心干掉)。
  • 静态资源请求会丢失代理用来路由的那些内部元数据。
  • 还有更怪的代理会把整个请求改成 generic-php-slug.php?someQueryParam=hugeEncodedString 这种鬼样子(这个我直接放弃支持了)。

这些代理的内部机制非常依赖查询参数和 URL 编码,而且都挺脆的。

一个很典型的例子是这样的静态资源 URL:

https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20

这里的 ?__pot=... 是代理自己的路由 / 状态参数,用来告诉代理这次请求其实要去哪个域名。

如果你把它剥掉,代理就没法正确处理这个请求,只能一路跌进各种 ServiceWorker 报错。

“资源域名替换”出场(以及它怎么很快就变成大坑)

有段时间我试过一个变通方案:先检测“当前是不是在代理后面”,如果是,就把所有指向 foony.io 的资源 URL 换成当前这个 origin,让代理眼里所有东西都变成同源的。

听起来挺合理,在 croxyproxy 里也确实能跑通,但它引进了很多额外复杂度:

  • 你得把 HTML 里已经存在的 linkscript 标签都替换一遍。
  • 还得挂一个 MutationObserver,处理那些运行时动态插入的标签(modulepreload、样式表之类)。
  • 你必须保留代理加在 URL 里的查询参数,否则就会搞坏它们自己的路由逻辑。而不同代理用的参数格式又各不相同,当然啦。
  • 你还得保证整套逻辑是通用的(不能依赖任何代理特有的全局变量),不然代码会迅速变成一坨又臭又长的垃圾场。

在这一步里,“base64 小技巧”又再次派上了用场:就连我自己写的 JavaScript,都得小心不要直接出现 location 这个字面量,因为代理可能会把它改掉。

反向看看 CroxyProxy 注入到页面里的脚本

做到这儿我开始好奇起来:这个代理到底在我的页面上做了些什么?只是插广告吗?还是更糟的东西?

CroxyProxy 注入到前端的脚本被做了很重的混淆。

(new Function(new TextDecoder('utf-8').decode(new Uint8Array((atob('NjY3NTZlN...')).match(/.{1,2}/g).map(b => parseInt(b, 16))))))();

运行之后,会变成这样:

function a0_0x5ebf(_0x213dc9,_0x1c49b6){var _0x4aa7c1=a0_0x4274();return a0_0x5ebf=function(_0x159600,_0x51d898){_0x159600=...

从这些特征看起来,croxyproxy 是用 Obfuscator.io 来做混淆的。好在用 webcrack 之类的工具把它还原回来还算轻松。

还原完之后,JavaScript 就变得好读多了:

((_0x15ca2c, _0x489eaa) => {
  if (typeof module == "object" && module.exports) {
    module.exports = _0x489eaa(require("./punycode"), require("./IPv6"), require("./SecondLevelDomains"));
  } else if (typeof define == "function" && define.amd) {
    define(["./punycode", "./IPv6", "./SecondLevelDomains"], _0x489eaa);
  } else {
    _0x15ca2c.URI = _0x489eaa(_0x15ca2c.punycode, _0x15ca2c.IPv6, _0x15ca2c.SecondLevelDomains, _0x15ca2c);
  }
})(this, function (_0x724467, _0x275183, _0x219d84, _0x2dcc2c) {
  var _0x114c1e = _0x2dcc2c && _0x2dcc2c.URI;
  function _0x5a9187(_0x2fd4ea, _0x3bd460) {
    var _0x23a83f = arguments.length >= 1;
    if (!(this instanceof _0x5a9187)) {

挺好,这下总算能看清它在干嘛了。然后……看起来大体还算正常。我猜这波混淆主要是为了让别人更难检测到自己是在代理后面浏览,大概是这样。

能看到有一些广告 / 界面注入:

    Bi(_0x308e2f) {
      console.log("Ads: " + _0x331b11.showAds);
      console.log("Ad codes: " + !!_0x331b11.adsJson);
      console.log("Adblock: " + _0x308e2f);
      _0x34fa18.document.body.insertAdjacentHTML("afterbegin", _0x331b11.modal);
      if (_0x331b11.header) {
        if (_0x331b11.header) {
          this.Ri(_0x308e2f);
        }
        [...document.querySelectorAll("#__cpsHeader a")].forEach(_0xcce595 => {
          _0xcce595.addEventListener("click", function (_0x3ef5f1) {
            if (this.target === "_blank") {
              _0x34fa18.open(this.href, "_blank").focus();
            } else {
              _0x34fa18.location.href = this.href;
            }
            _0x3ef5f1.stopImmediatePropagation();
            _0x3ef5f1.preventDefault();
          }, true);
        });
      }
      return this;
    }

别的地方也有类似的代码,但基本就是负责展示广告,包括那种弹出在后面的 pop-under 广告。它还用上了 FuckAdBlock

不过,真正那一大堆字符串替换其实都是在服务端做的。那些服务端逻辑到底都干了些什么,谁知道呢

无论如何,如果你在乎自己的账号安全,真的不该用这些网页代理。如果实在非用不可,至少别在里面输入任何个人信息、账号密码、支付信息之类的东西。

把“资源替换方案”扔进垃圾桶

我最后决定,资源域名替换带来的那些复杂度,再加上为了兼容 foony.io 其它地方的各种特别处理,完全配不上那点因为“请求里没有 cookie”而省下来的小流量。

而且自从上了 foony.io 之后,我们还看到游戏开始率有一段时间莫名其妙地下降,我怀疑 foony.io 这套方案里还有一些我们没发现的问题。

所以我干脆把 foony.io 删掉了,至少目前是这样。

一旦把 foony.io 的 CDN 逻辑统统删掉,所有东西都统一走 foony.com 之后,代理兼容一下子就简单多了:

  • 静态资源和页面全部同源加载。
  • 不用再给代理的 ServiceWorker 解释一堆“特殊情况”。
  • 少了很多 URL 改写。
  • 代码也不那么脆。

简单说,把 foony.io 干掉其实就是一次架构上的“减负”,直接缩小了各种奇怪代理行为能捣乱的范围。


第三轮:哪些能用、哪些不能、以及为什么

已确认可用的代理

到这里为止,Foony 已经可以在这些代理后面正常运行:

  • croxyproxy
  • proxyorb

其它一些代理大概也能用,不过我猜大多数还是会挂。至少大家常用来玩游戏的那几家,现在总算是能跑通了。

为什么不是“所有代理都能用”?

有些代理从底子上就撑不起一款现代多人 Web 游戏,比如:

  • 对 HTTPS 支持不完整的代理。
  • 会把 WebSockets 弄坏或者直接拦掉的代理(Foony 需要实时网络)。理论上可以绕一圈解决,但复杂度会爆炸。
  • 在跨域请求、请求头、或者 ServiceWorker 上限制特别多的代理。

关键总结

网页代理其实非常不安全

它们本质上就是一层中间人,会:

  • 改写 HTML;
  • 改写 JavaScript;
  • 有时会注入自己的 ServiceWorker;
  • 经常依赖查询参数 / URL 编码来路由请求;
  • 还能用各种方式随便动你的页面。

让我挺意外的是,有些代理下手居然这么深:连 shader 源码字符串注释 都要改,至于还有什么东西被它们顺手动了,真就只有天知道了。

有时候,最好的修复是改架构而不是打补丁

WebGL 那个补丁能让游戏重新渲染出来,但是真正让代理支持长期稳定下来的,其实是把多域名 CDN 这整套策略撤掉。

这也算给自己提了个醒:很多看起来很聪明的小优化,在理想环境里都很合理,一旦撞上各种“不友好”的中间层就原形毕露。比如代理、中间人,或者用户的浏览器扩展、Safari、语言设置、辅助功能,甚至太阳风暴,总之什么都有可能把它们搞挂。


总结

现在 Foony 已经能在几家关键的代理(croxyproxy 和 proxyorb)后面正常工作了,而且代码库也没因此变成一坨依赖各种代理特性的烂摊子:

  • 一套通用的 Three.js shader 修复方案(不依赖任何代理特有的标识符)。
  • 更简单的域名策略(全部走 foony.com)。
8 Ball Pool online multiplayer billiards icon