

1/1/1970
我是怎么让 Foony 在代理后面正常运行的
嗨!我早就知道网页代理会给网站带来各种兼容性问题。不过,Foony 在代理里的兼容性一直都出了名的糟糕,要把这些代理相关的问题彻底搞定,其实挺棘手的。
这也不是那种“Foony 用了什么特别冷门的 API”导致的(虽然我们确实用了)。真正的原因是好几件事叠在一起:
- 一些代理会在根本不该动的地方,疯狂改写字符串。
- 一些代理把主站域名和“其他”域名(CDN、静态资源域名之类)当成两种东西来处理。
- 还有一个残酷现实:有些代理就是撑不起现代 Web 应用,比如 HTTPS 处理不对、WebSockets 跑不通之类。
我们暂时还做不到在所有代理里都能跑,不过现在至少能在 croxyproxy 和 proxyorb 里正常工作,这就是这次折腾的目标。
下面我会讲:到底哪里坏了、为什么会坏、以及真正有用的修补办法。
第一轮:明明合法,却会报错的 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 里已经存在的
link和script标签都替换一遍。 - 还得挂一个
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)。