P0-1 被控端自更新境内必然失败(裸 fetch 不读代理): - 新增 apps/agent/src/net/proxy.ts + index.ts:sidecar 已验证出网方案的 TS 移植(NA_PROXY → 环境变量 → 系统代理;PAC/socks 明确报错不静默降级; 重定向链共享超时预算;CONNECT 自管 abort;TLS 默认校验证书)。 只依赖 node 内置模块,不 import 项目内任何模块(依赖严格单向) - selfupdate.download 改走代理;成功/失败均带回 via/proxy/proxy_source/ outbound_notes 诊断;错误分类同款移植 sidecar main.mjs:146-165 (ECONNRESET 不得报成「请求超时」——真机教训) - httpGet 增 maxBytes 双闸(content-length 预检 + 流式中断), 保住「64MB 上限」安全属性 - 回环目标恒直连(CI/dev 机 HTTP_PROXY 不得误送自建镜像进代理—— e2e 实踩:代理另一侧的 localhost 不是本机 → 404) A-P0-3 LKG 只有指纹没有内容指针: - LkgRecord 只增 hash_full(64 位全量)+ backup_glob(.bak*)两字段; 新增 lkgHashMatches(全量优先、老记录回落 12 位前缀,不猜) - selfupdate 备份保留最近 3 个(planBackupCleanup 纯函数,最旧先删) P1-4 system.audit.l2 returns_schema 空壳 → 四 action 属性并集补全; AgentUpdate returns 增 via/proxy/proxy_source/outbound_notes 四个只增字段 安全属性逐条不变:sha256 强制校验 / 先校验后落盘 / 写后校验失败回滚 / TLS 默认校验证书 / 只接受 http(s) 测试:新增 agent-proxy 17 例 + selfupdate-backup-cleanup 8 例 + lkg 新增 4 例;单测 403/403、e2e 33/33、typecheck 全绿
562 lines
23 KiB
JavaScript
562 lines
23 KiB
JavaScript
/**
|
||
* 被控端出站代理守卫(`apps/agent/src/net/proxy.ts`,v2.1.0)。
|
||
*
|
||
* ⚠️ 背景(真机缺陷,与控制台 sidecar 同源)
|
||
* 被控端拉取式自更新 `system.agent.update` 原为裸 `fetch` —— Node 内置 fetch
|
||
* 默认不读任何代理环境变量,也读不到 Windows/macOS 系统代理,而中文用户配的
|
||
* 恰恰是「系统代理」。结果:境内机器即使代理一切正常,自更新也**必然**失败
|
||
* (`fetch failed`,零诊断信息)。修复 = 把 sidecar 已验证的出网方案
|
||
* (`apps/console/sidecar/proxy.mjs`,16 用例守卫)同款移植为 TS。
|
||
*
|
||
* 本文件守四件事(口径逐条对标 `tests/unit/sidecar-proxy.test.mjs`):
|
||
* ① **决议逻辑**(纯函数):代理来源优先级、NO_PROXY、系统代理形态解析;
|
||
* ② **真发请求**(本地服务器,离线可跑):直连 / 绝对形态 / CONNECT 隧道 /
|
||
* 重定向链共享预算 / maxBytes 双闸;
|
||
* ③ **错误分类**(selfupdate.ts 同款移植):ECONNRESET 不得报成「请求超时」;
|
||
* ④ **备份清理纯函数**:保留最近 3 个。
|
||
*
|
||
* 夹具同样取自真机:macOS 的是 `scutil --proxy` 原样输出形态;Windows 的是
|
||
* `reg query …\Internet Settings` 原样输出形态(含「值不存在时 reg 走 stderr」)。
|
||
*/
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import http from 'node:http';
|
||
import net from 'node:net';
|
||
import {
|
||
detectSystemProxy,
|
||
httpGet,
|
||
isBypassed,
|
||
isLoopbackTarget,
|
||
normalizeProxyUrl,
|
||
parseRegQueryProxy,
|
||
parseScutilProxy,
|
||
pickEnvProxy,
|
||
resolveProxy,
|
||
tunnelConnection,
|
||
} from '../../apps/agent/dist/net/proxy.js';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 夹具(真机原样形态)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 本机(macOS)真实输出形态:透明代理扩展接管 → PAC 模式,HTTP/HTTPS 开关都是 0。 */
|
||
const SCUTIL_PAC = `<dictionary> {
|
||
ExceptionsList : <array> {
|
||
0 : localhost
|
||
1 : 127.0.0.0/8
|
||
2 : ::1
|
||
}
|
||
FTPPassive : 1
|
||
HTTPEnable : 0
|
||
HTTPSEnable : 0
|
||
ProxyAutoConfigEnable : 1
|
||
ProxyAutoConfigURLString : http://localhost:51431/proxy.pac
|
||
ProxyAutoDiscoveryEnable : 0
|
||
SOCKSEnable : 0
|
||
}
|
||
`;
|
||
|
||
/** 手工开系统代理时的形态(字段顺序与本机实测输出一致)。 */
|
||
const SCUTIL_ON = `<dictionary> {
|
||
ExceptionsList : <array> {
|
||
0 : *.local
|
||
1 : 169.254/16
|
||
}
|
||
HTTPEnable : 1
|
||
HTTPPort : 7890
|
||
HTTPProxy : 127.0.0.1
|
||
HTTPSEnable : 1
|
||
HTTPSPort : 7891
|
||
HTTPSProxy : 127.0.0.1
|
||
ProxyAutoConfigEnable : 0
|
||
SOCKSEnable : 0
|
||
}
|
||
`;
|
||
|
||
/**
|
||
* 真机(Windows 11 ARM64 虚拟机)上 `reg query … /v ProxyEnable` 的真实输出形态(值为 0x0)。
|
||
*
|
||
* 注:本文件里的地址一律用**文档保留段**(RFC 5737 的 `192.0.2.0/24`)或回环,
|
||
* 不写任何真机的内网地址 —— 公开仓库的隐私审查会扫内网 IP。
|
||
*/
|
||
const REG_OFF = `
|
||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||
ProxyEnable REG_DWORD 0x0
|
||
|
||
`;
|
||
|
||
/** 开启系统代理的形态(与真机同一列宽:4 空格缩进 + 类型名 + 值)。 */
|
||
const REG_ON = `
|
||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||
ProxyEnable REG_DWORD 0x1
|
||
ProxyOverride REG_SZ *.local;<local>
|
||
ProxyServer REG_SZ 192.0.2.10:7890
|
||
`;
|
||
|
||
const REG_PER_PROTOCOL = `
|
||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||
ProxyEnable REG_DWORD 0x1
|
||
ProxyServer REG_SZ http=127.0.0.1:7890;https=127.0.0.1:7891
|
||
`;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ① 纯函数
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('代理 URL 归一化:补 scheme、拒 socks、拒垃圾', () => {
|
||
assert.equal(normalizeProxyUrl('http://127.0.0.1:7890')?.href, 'http://127.0.0.1:7890/');
|
||
assert.equal(normalizeProxyUrl('127.0.0.1:7890')?.hostname, '127.0.0.1', '缺 scheme 应补 http://');
|
||
assert.equal(normalizeProxyUrl(' https://proxy.local:8443 ')?.protocol, 'https:');
|
||
assert.equal(normalizeProxyUrl('socks5://127.0.0.1:1080'), null, 'SOCKS 尚不支持 → 必须返回 null(上层据此出声)');
|
||
assert.equal(normalizeProxyUrl(''), null);
|
||
assert.equal(normalizeProxyUrl(' '), null);
|
||
assert.equal(normalizeProxyUrl(undefined), null);
|
||
assert.equal(normalizeProxyUrl('http://'), null, '没有主机名不算代理');
|
||
});
|
||
|
||
test('NO_PROXY 命中判定:精确 / 后缀 / 端口 / 通配 / 不误伤 / CIDR 不猜', () => {
|
||
const U = 'https://github.com/x';
|
||
assert.equal(isBypassed(U, ''), false);
|
||
assert.equal(isBypassed(U, ' '), false);
|
||
assert.equal(isBypassed(U, 'github.com'), true);
|
||
assert.equal(isBypassed(U, '.github.com'), true, '前导点等价于后缀匹配');
|
||
assert.equal(isBypassed('https://api.github.com/x', 'github.com'), true, '条目应覆盖其子域');
|
||
assert.equal(isBypassed(U, 'api.github.com'), false, '条目比目标更窄时不得反向豁免父域');
|
||
assert.equal(isBypassed(U, 'github.io'), false, '不同后缀不得误伤');
|
||
assert.equal(isBypassed(U, 'evilgithub.com'), false, '拼尾不得误伤');
|
||
assert.equal(isBypassed(U, 'github.com:443'), true, '端口相符应命中');
|
||
assert.equal(isBypassed(U, 'github.com:8080'), false, '端口不符不命中');
|
||
assert.equal(isBypassed(U, ' example.com , github.com '), true, '空白与多条目应容错');
|
||
assert.equal(isBypassed(U, '*'), true);
|
||
// CIDR 条目(scutil 的 ExceptionsList 里就有 169.254/16)——不猜:解析不了就当不命中,
|
||
// 由调用方记 note,绝不自作主张按网段放行(猜错 = 该走代理的没走)
|
||
assert.equal(isBypassed('https://169.254.1.1/x', '169.254/16'), false, 'CIDR 不做网段猜测');
|
||
assert.equal(isBypassed('https://127.0.0.1/x', '127.0.0.0/8'), false, 'CIDR 不做网段猜测(回环同理)');
|
||
});
|
||
|
||
test('代理来源优先级:NA_PROXY > 协议环境变量 > ALL_PROXY', () => {
|
||
const U = 'https://github.com/x';
|
||
assert.equal(pickEnvProxy({ NA_PROXY: 'http://a:1', HTTPS_PROXY: 'http://b:2' }, U).source, 'NA_PROXY');
|
||
assert.equal(pickEnvProxy({ HTTPS_PROXY: 'http://b:2', ALL_PROXY: 'http://c:3' }, U).source, 'HTTPS_PROXY');
|
||
assert.equal(pickEnvProxy({ https_proxy: 'http://b:2' }, U).source, 'https_proxy', '小写形态同样生效');
|
||
assert.equal(pickEnvProxy({ ALL_PROXY: 'http://c:3' }, U).source, 'ALL_PROXY');
|
||
assert.equal(pickEnvProxy({}, U).url, null);
|
||
// 只看 http 目标时不认 HTTPS_PROXY
|
||
assert.equal(pickEnvProxy({ HTTPS_PROXY: 'http://b:2' }, 'http://x/y').url, null);
|
||
assert.equal(pickEnvProxy({ HTTP_PROXY: 'http://d:4' }, 'http://x/y').source, 'HTTP_PROXY');
|
||
});
|
||
|
||
test('环境变量写了 SOCKS 时必须出声,而不是静默直连', () => {
|
||
const r = pickEnvProxy({ HTTPS_PROXY: 'socks5://127.0.0.1:1080' }, 'https://github.com/x');
|
||
assert.equal(r.url, null);
|
||
assert.equal(r.notes.length, 1);
|
||
assert.match(r.notes[0], /socks5/, 'note 要带上原值,便于用户自查');
|
||
assert.match(r.notes[0], /已忽略/);
|
||
// 下一个可用来源仍应被采纳(不能因为前一个没看懂就整个放弃)
|
||
const r2 = pickEnvProxy({ HTTPS_PROXY: 'socks5://x:1', ALL_PROXY: 'http://ok:8080' }, 'https://github.com/x');
|
||
assert.equal(r2.source, 'ALL_PROXY');
|
||
});
|
||
|
||
test('解析 scutil:HTTPS 优先、PAC 模式出声、全关则无代理', () => {
|
||
const on = parseScutilProxy(SCUTIL_ON);
|
||
assert.equal(on.url, '127.0.0.1:7891', 'HTTPS 优先于 HTTP');
|
||
assert.equal(on.bypass, '*.local,169.254/16', 'ExceptionsList 应被收集成绕过列表');
|
||
assert.deepEqual(on.notes, []);
|
||
|
||
const pac = parseScutilProxy(SCUTIL_PAC);
|
||
assert.equal(pac.url, null);
|
||
assert.match(pac.notes.join(''), /PAC/, 'PAC 不可解析必须明说,否则用户只会看到 fetch failed');
|
||
|
||
const off = parseScutilProxy('<dictionary> {\n HTTPEnable : 0\n HTTPSEnable : 0\n}\n');
|
||
assert.equal(off.url, null);
|
||
assert.deepEqual(off.notes, [], '既没开代理也不是 PAC,就不该报噪音 note');
|
||
|
||
assert.equal(parseScutilProxy('not a dict'), null);
|
||
assert.equal(parseScutilProxy(''), null);
|
||
});
|
||
|
||
test('解析 reg query:0x0 即显式关闭、分协议形态取 https、<local> 剔除', () => {
|
||
const off = parseRegQueryProxy(REG_OFF);
|
||
assert.equal(off.url, null, 'ProxyEnable=0x0 是显式关闭,哪怕别的值有内容也不能启用');
|
||
|
||
const on = parseRegQueryProxy(REG_ON);
|
||
assert.equal(on.url, '192.0.2.10:7890');
|
||
assert.equal(on.bypass, '*.local', '<local> 是占位符,不是域名,必须剔除');
|
||
|
||
const per = parseRegQueryProxy(REG_PER_PROTOCOL);
|
||
assert.equal(per.url, '127.0.0.1:7891', '分协议形态应取 https(我们没有明文 http 目标)');
|
||
|
||
// 值不存在时 reg 把错误写在 stderr,stdout 只剩表头 —— 不该崩,也不该瞎猜
|
||
const missing = parseRegQueryProxy(
|
||
'\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\n\n',
|
||
);
|
||
assert.equal(missing, null);
|
||
assert.equal(parseRegQueryProxy('ERROR: The system was unable to find…'), null);
|
||
});
|
||
|
||
test('系统代理探测:命令失败/平台不支持一律视为「没有系统代理」,且 run 可注入', async () => {
|
||
// 探测失败绝不能让自更新本身报错
|
||
assert.equal(await detectSystemProxy('win32', async () => null), null);
|
||
assert.equal(await detectSystemProxy('darwin', async () => null), null);
|
||
assert.equal(await detectSystemProxy('linux', async () => 'whatever'), null, 'Linux 不做猜测');
|
||
const boom = await detectSystemProxy('win32', async () => {
|
||
throw new Error('安全软件拦截');
|
||
});
|
||
assert.equal(boom, null, '抛异常也要吞掉');
|
||
|
||
// 真解析路径(假 runner)
|
||
const win = await detectSystemProxy('win32', async (file, args) => {
|
||
assert.equal(file, 'reg');
|
||
assert.equal(args[0], 'query');
|
||
assert.match(args[1], /Internet Settings$/, '必须查 HKCU 的 Internet Settings');
|
||
return REG_ON;
|
||
});
|
||
assert.equal(win.url, '192.0.2.10:7890');
|
||
assert.equal(
|
||
(await detectSystemProxy('darwin', async (f) => (f === 'scutil' ? SCUTIL_ON : null))).url,
|
||
'127.0.0.1:7891',
|
||
);
|
||
});
|
||
|
||
test('回环目标恒直连:CI/dev 机的 HTTP_PROXY 不得把「自建镜像/测试源站」误送进代理', async () => {
|
||
// 真景(本仓库 e2e 实踩):CI 与开发机普遍设 HTTP_PROXY=http://127.0.0.1:xxxx,
|
||
// agent 继承后把发往回环源站的请求交给代理 → 代理另一侧的 localhost 不是这台机器 → 404。
|
||
assert.equal(isLoopbackTarget('http://127.0.0.1:8080/agent.mjs'), true);
|
||
assert.equal(isLoopbackTarget('http://127.8.9.10/x'), true, '整个 127/8 段都算回环');
|
||
assert.equal(isLoopbackTarget('http://localhost:9000/x'), true);
|
||
assert.equal(isLoopbackTarget('http://[::1]:9000/x'), true);
|
||
assert.equal(isLoopbackTarget('http://127.999.1.1/x'), false, '非法字节不算(不猜)');
|
||
assert.equal(isLoopbackTarget('https://github.com/x'), false, '公开域名不受影响');
|
||
|
||
// 即便显式配了 NA_PROXY + HTTP_PROXY,回环目标仍直连(代理够不着本机服务)
|
||
const r = await resolveProxy({
|
||
targetUrl: 'http://127.0.0.1:1234/agent.mjs',
|
||
env: { NA_PROXY: 'http://127.0.0.1:9999', HTTP_PROXY: 'http://127.0.0.1:8888' },
|
||
system: { url: '192.0.2.10:7890', bypass: '', notes: [] },
|
||
});
|
||
assert.equal(r.proxy, null, '回环目标不得走代理');
|
||
assert.match(r.notes.join(''), /回环/);
|
||
});
|
||
|
||
test('决议代理:NO_PROXY 短路 → 环境变量 → 系统代理兜底(含 note 累积与系统 bypass)', async () => {
|
||
const U = 'https://github.com/x';
|
||
|
||
// NO_PROXY 直接短路(哪怕环境里有代理)
|
||
const bypass = await resolveProxy({ targetUrl: U, env: { HTTPS_PROXY: 'http://a:1', NO_PROXY: 'github.com' }, system: null });
|
||
assert.equal(bypass.proxy, null);
|
||
assert.match(bypass.notes.join(''), /NO_PROXY/);
|
||
|
||
// 环境变量优先于系统代理
|
||
const envFirst = await resolveProxy({
|
||
targetUrl: U,
|
||
env: { HTTPS_PROXY: 'http://env:1' },
|
||
system: { url: 'sys:2', bypass: '', notes: [] },
|
||
});
|
||
assert.equal(envFirst.proxy.href, 'http://env:1/');
|
||
assert.equal(envFirst.source, 'HTTPS_PROXY');
|
||
|
||
// 环境变量空 → 用系统代理,并把来源标成 system
|
||
const sys = await resolveProxy({
|
||
targetUrl: U,
|
||
env: {},
|
||
system: { url: '127.0.0.1:7890', bypass: '', notes: [] },
|
||
});
|
||
assert.equal(sys.proxy.href, 'http://127.0.0.1:7890/', '缺 scheme 的系统代理值也应能用');
|
||
assert.equal(sys.source, 'system');
|
||
|
||
// 系统代理的绕过列表命中 → 直连
|
||
const sysBypass = await resolveProxy({
|
||
targetUrl: U,
|
||
env: {},
|
||
system: { url: '127.0.0.1:7890', bypass: 'github.com', notes: [] },
|
||
});
|
||
assert.equal(sysBypass.proxy, null);
|
||
assert.match(sysBypass.notes.join(''), /绕过列表/);
|
||
|
||
// 完全没有代理:notes 应带出「为什么没用上」(PAC / socks),便于用户自查
|
||
const none = await resolveProxy({
|
||
targetUrl: U,
|
||
env: { HTTPS_PROXY: 'socks5://x:1' },
|
||
system: { url: null, bypass: '', notes: ['PAC 无法解析'] },
|
||
});
|
||
assert.equal(none.proxy, null);
|
||
assert.match(none.notes.join(';'), /socks5/);
|
||
assert.match(none.notes.join(';'), /PAC/);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ② 真发请求(本地服务器,离线)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 起一个最小 HTTP 源站,返回 {srv, url}。 */
|
||
async function startOrigin(handler) {
|
||
const srv = http.createServer(handler);
|
||
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
||
const port = (srv.address()).port;
|
||
return { srv, url: (p) => `http://127.0.0.1:${port}${p}` };
|
||
}
|
||
|
||
/** 起一个「真转发」的 HTTP 代理;记录它看到的请求行(用于判定是否走了绝对形态)。 */
|
||
async function startHttpProxy() {
|
||
const seen = [];
|
||
const srv = http.createServer((req, res) => {
|
||
seen.push(req.url ?? '');
|
||
let u;
|
||
try {
|
||
u = new URL(req.url ?? '');
|
||
} catch {
|
||
res.writeHead(400);
|
||
res.end('not absolute-form');
|
||
return;
|
||
}
|
||
const up = http.request(
|
||
{
|
||
host: u.hostname,
|
||
port: u.port,
|
||
path: `${u.pathname}${u.search}`,
|
||
method: req.method,
|
||
headers: { ...req.headers, host: u.host },
|
||
},
|
||
(r) => {
|
||
res.writeHead(r.statusCode, r.headers);
|
||
r.pipe(res);
|
||
},
|
||
);
|
||
up.on('error', () => {
|
||
res.writeHead(502);
|
||
res.end('upstream error');
|
||
});
|
||
req.pipe(up);
|
||
});
|
||
// CONNECT 也要支持:https 目标经代理走的就是它
|
||
srv.on('connect', (req, clientSock, head) => {
|
||
seen.push(`CONNECT ${req.url}`);
|
||
const [host, port] = (req.url ?? '').split(':');
|
||
const up = net.connect(Number(port), host, () => {
|
||
clientSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||
if (head?.length) up.write(head);
|
||
up.pipe(clientSock);
|
||
clientSock.pipe(up);
|
||
});
|
||
up.on('error', () => clientSock.destroy());
|
||
});
|
||
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
||
return { srv, seen, url: `http://127.0.0.1:${(srv.address()).port}` };
|
||
}
|
||
|
||
test('httpGet 直连:跟随 302 且返回最终响应', async () => {
|
||
const origin = await startOrigin((req, res) => {
|
||
if (req.url === '/hop') {
|
||
res.writeHead(302, { location: '/final' });
|
||
res.end();
|
||
return;
|
||
}
|
||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||
res.end('ARRIVED');
|
||
});
|
||
try {
|
||
const r = await httpGet(origin.url('/hop'), { timeoutMs: 5000 });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.body.toString(), 'ARRIVED');
|
||
assert.equal(r.url, origin.url('/final'), '应报出最终 URL');
|
||
} finally {
|
||
origin.srv.close();
|
||
}
|
||
});
|
||
|
||
test('httpGet 经代理:请求行走绝对形态(不是直连)', async () => {
|
||
const origin = await startOrigin((_req, res) => {
|
||
res.writeHead(200);
|
||
res.end('VIA-PROXY');
|
||
});
|
||
const proxy = await startHttpProxy();
|
||
try {
|
||
const r = await httpGet(origin.url('/agent.mjs'), { proxy: new URL(proxy.url), timeoutMs: 5000 });
|
||
assert.equal(r.body.toString(), 'VIA-PROXY');
|
||
assert.match(proxy.seen[0], /^http:\/\/127\.0\.0\.1:\d+\/agent\.mjs$/, 'HTTP 代理收到的是绝对 URI');
|
||
} finally {
|
||
proxy.srv.close();
|
||
origin.srv.close();
|
||
}
|
||
});
|
||
|
||
test('httpGet 经代理:代理不通时必须报错(证明真的走了代理而非偷偷直连)', async () => {
|
||
const origin = await startOrigin((_req, res) => {
|
||
res.writeHead(200);
|
||
res.end('SHOULD-NOT-BE-REACHED');
|
||
});
|
||
try {
|
||
await assert.rejects(
|
||
// 死端口:若实现「代理失败就自动直连」,这里会拿到 200 —— 那正是我们最怕的静默降级
|
||
httpGet(origin.url('/x'), { proxy: new URL('http://127.0.0.1:1'), timeoutMs: 5000 }),
|
||
/ECONNREFUSED|代理/,
|
||
);
|
||
} finally {
|
||
origin.srv.close();
|
||
}
|
||
});
|
||
|
||
test('httpGet:整个重定向链共享同一个超时预算(不是每跳各自计时)', async () => {
|
||
// /a → 302 /b → 302 /hang(挂起不响应)。若每跳各自计时,总耗 = 3×timeout;
|
||
// 共享预算下总耗 ≈ 1×timeout 后整链 abort。
|
||
const origin = await startOrigin((req, res) => {
|
||
if (req.url === '/a' || req.url === '/b') {
|
||
res.writeHead(302, { location: req.url === '/a' ? '/b' : '/hang' });
|
||
res.end();
|
||
return;
|
||
}
|
||
// /hang:什么都不回
|
||
});
|
||
const timeoutMs = 600;
|
||
const t0 = Date.now();
|
||
try {
|
||
await assert.rejects(httpGet(origin.url('/a'), { timeoutMs }), /abort|中止|超时/i);
|
||
const dt = Date.now() - t0;
|
||
assert.ok(
|
||
dt < timeoutMs * 2,
|
||
`重定向链应共享预算(总耗 ≈ ${timeoutMs}ms),实际 ${dt}ms —— 说明每跳各自计时,会穿过 RPC 超时`,
|
||
);
|
||
} finally {
|
||
origin.srv.closeAllConnections();
|
||
origin.srv.close();
|
||
}
|
||
});
|
||
|
||
test('httpGet:maxBytes 第一闸 —— content-length 声明超限立即中断', async () => {
|
||
const origin = await startOrigin((_req, res) => {
|
||
res.writeHead(200, { 'content-length': '1000' });
|
||
res.end('x'.repeat(1000));
|
||
});
|
||
try {
|
||
await assert.rejects(
|
||
httpGet(origin.url('/big'), { timeoutMs: 5000, maxBytes: 100 }),
|
||
/超过上限/,
|
||
'声明超限时不该开始读 body',
|
||
);
|
||
} finally {
|
||
origin.srv.close();
|
||
}
|
||
});
|
||
|
||
test('httpGet:maxBytes 第二闸(流式)—— 不报 content-length 时收到即累计、超限即断', async () => {
|
||
// 服务端不设 content-length、持续写 —— 谎报/缺报时内存不能被无界吃爆
|
||
const origin = await startOrigin((_req, res) => {
|
||
res.writeHead(200);
|
||
// 分多块写,每块之间让出事件循环(保证 data 事件真的分批到达)
|
||
let n = 0;
|
||
const timer = setInterval(() => {
|
||
n += 1;
|
||
if (n > 50 || res.writableEnded) {
|
||
clearInterval(timer);
|
||
res.end();
|
||
return;
|
||
}
|
||
res.write('x'.repeat(100));
|
||
}, 2);
|
||
res.on('close', () => clearInterval(timer));
|
||
});
|
||
try {
|
||
await assert.rejects(
|
||
httpGet(origin.url('/stream'), { timeoutMs: 5000, maxBytes: 200 }),
|
||
/超过上限/,
|
||
);
|
||
} finally {
|
||
origin.srv.closeAllConnections();
|
||
origin.srv.close();
|
||
}
|
||
});
|
||
|
||
test('abort 必须覆盖隧道与 TLS 握手:代理收下 CONNECT 却装死时,超时要立即生效', async () => {
|
||
// ⚠️ 真机踩过(sidecar 2026-10-13):配置 75s 超时,实际挂了 **109s** 才失败。
|
||
// 根因:`createConnection` 回调返回之前外层 `http.request` 还没建立,
|
||
// 外层的 signal / timeout **无人受理**;若此时卡在 CONNECT 或 TLS 握手上,
|
||
// 超时形同虚设。本用例把那个形态固定下来:
|
||
// 代理回 200 之后就一个字都不说 → TLS 握手永远完不成。
|
||
const srv = http.createServer();
|
||
srv.on('connect', (_req, sock) => {
|
||
sock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||
// 装死:不再说一个字
|
||
});
|
||
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
||
const t0 = Date.now();
|
||
let watchdog;
|
||
try {
|
||
await assert.rejects(
|
||
// 看门狗:没有它,一旦 abort 失效本用例会**挂死**(把整个测试套件拖住),
|
||
// 而不是干脆地变红。有它则 4s 后必定失败,且失败点落在下面的 dt 断言上。
|
||
Promise.race([
|
||
httpGet('https://github.com/x', {
|
||
proxy: new URL(`http://127.0.0.1:${(srv.address()).port}`),
|
||
timeoutMs: 800,
|
||
}),
|
||
new Promise((_never, rej) => {
|
||
watchdog = setTimeout(() => rej(new Error('看门狗触发:abort 未生效')), 4000);
|
||
}),
|
||
]),
|
||
/abort|中止|超时/i,
|
||
);
|
||
const dt = Date.now() - t0;
|
||
assert.ok(dt < 4000, `超时应立即生效(配置 800ms),实际 ${dt}ms —— 说明 abort 没覆盖隧道/TLS 阶段`);
|
||
} finally {
|
||
clearTimeout(watchdog);
|
||
srv.closeAllConnections();
|
||
srv.close();
|
||
}
|
||
});
|
||
|
||
test('CONNECT 隧道:可用,且代理拒绝(非 200)时给出明确错误', async () => {
|
||
const origin = await startOrigin((_req, res) => {
|
||
res.writeHead(200);
|
||
res.end('TUNNELED');
|
||
});
|
||
const proxy = await startHttpProxy();
|
||
try {
|
||
// 直接用隧道 socket 说一句裸 HTTP/1.1(这里刻意用 http 目标:隧道层不带 TLS,
|
||
// TLS 叠层由真机端到端验证覆盖)
|
||
const socket = await new Promise((resolve, reject) => {
|
||
const mk = tunnelConnection(
|
||
new URL(proxy.url),
|
||
{ protocol: 'http:', hostname: '127.0.0.1', port: String((origin.srv.address()).port) },
|
||
undefined,
|
||
);
|
||
mk({}, (err, s) => (err ? reject(err) : resolve(s)));
|
||
});
|
||
const text = await new Promise((resolve) => {
|
||
let buf = '';
|
||
socket.on('data', (d) => {
|
||
buf += d;
|
||
if (buf.includes('TUNNELED')) {
|
||
socket.destroy();
|
||
resolve(buf);
|
||
}
|
||
});
|
||
socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`);
|
||
});
|
||
assert.match(text, /200 OK/);
|
||
assert.match(proxy.seen.join('|'), /^CONNECT 127\.0\.0\.1:\d+$/, '代理应收到 CONNECT');
|
||
|
||
// 代理拒绝(非 200)→ 必须立刻报错并关掉 socket,而不是挂着
|
||
const badProxy = http.createServer();
|
||
badProxy.on('connect', (_req, sock) => {
|
||
sock.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||
sock.destroy();
|
||
});
|
||
await new Promise((r) => badProxy.listen(0, '127.0.0.1', r));
|
||
await assert.rejects(
|
||
new Promise((resolve, reject) => {
|
||
const mk = tunnelConnection(
|
||
new URL(`http://127.0.0.1:${(badProxy.address()).port}`),
|
||
{ protocol: 'http:', hostname: '127.0.0.1', port: '80' },
|
||
undefined,
|
||
);
|
||
mk({}, (err, s) => (err ? reject(err) : resolve(s)));
|
||
}),
|
||
/代理拒绝 CONNECT/,
|
||
);
|
||
badProxy.close();
|
||
} finally {
|
||
proxy.srv.close();
|
||
origin.srv.close();
|
||
}
|
||
});
|