Files
nodeagent/tests/unit/agent-proxy.test.mjs
T
jkooe 1249c90725 feat(agent): selfupdate 代理出网 + LKG 内容指针 + L2/AgentUpdate 契约补全
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 全绿
2026-10-25 23:58:30 +08:00

562 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 被控端出站代理守卫(`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();
}
});