码桶

发现社区成员的开源项目

main
NodeNanny/core/socks-fetch.js
socks-fetch.js8.1 KB
'use strict';

// 流量池新项目·批次一:通用 SOCKS5 出口探测工具。
//
// 现有 usability-check.js 里手写的 socksHttpProbe 只支持明文 http——当时的场景
// 只是"测一下 gstatic 的 204 端点",够用。但三层检测器要测速(下载真实文件)和
// 真实性验证(访问真实网站),这年头几乎所有正经网站/CDN 都是强制 https,
// 明文 http 探测器测不了。
//
// 好消息是不需要像 SOCKS5 握手那样手写 TLS——Node 内置的 tls 模块支持
// tls.connect({ socket }) 直接在一条已经建立好的原始 socket 上做 TLS 握手,
// 相当于"套壳"而不是重新实现协议。SOCKS5 CONNECT 拿到的那条隧道,套上 tls 就是
// 一条到目标网站的加密连接,跟直连没有本质区别。
//
// 本模块只依赖 Node 内置的 net / tls,不引入任何 npm 依赖。

const net = require('net');
const tls = require('tls');

// 走 SOCKS5 CONNECT,拿到一条到 targetHost:targetPort 的原始隧道 socket。
function socksConnect({ socksHost, socksPort, targetHost, targetPort, connectTimeoutMs }) {
  return new Promise((resolve, reject) => {
    const socket = net.connect(socksPort, socksHost);
    let stage = 'connecting';
    let settled = false;

    const fail = (err) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      socket.removeAllListeners();
      socket.destroy();
      reject(err);
    };
    const succeed = () => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      socket.removeAllListeners('error');
      socket.removeAllListeners('data');
      resolve(socket);
    };

    // 阶段信息(connecting/greeting/connect)一起带进错误消息里,供上层(pool-checker.js的
    // classifyProbeError)区分"本机代理客户端自己没起来"和"本机代理正常、只是远程节点一直没接通"
    // 这两种完全不同的情况——见2026-07-14排查记录:19个候选里15个卡在connect阶段,
    // 用真机诊断证实这是节点本身连不上,不是本机资源/并发问题(并发1和并发5结果完全一致)。
    const timer = setTimeout(() => fail(new Error(`SOCKS5连接超时[阶段:${stage}]`)), connectTimeoutMs);

    socket.on('error', (err) => fail(err));
    socket.on('connect', () => {
      stage = 'greeting';
      socket.write(Buffer.from([0x05, 0x01, 0x00]));
    });
    socket.on('data', (chunk) => {
      if (stage === 'greeting') {
        if (chunk.length < 2 || chunk[0] !== 0x05 || chunk[1] !== 0x00) {
          return fail(new Error('SOCKS5握手失败(本机代理客户端没起来或配置有误)'));
        }
        stage = 'connect';
        const hostBuf = Buffer.from(targetHost, 'utf8');
        const req = Buffer.concat([
          Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
          hostBuf,
          Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
        ]);
        socket.write(req);
        return;
      }
      if (stage === 'connect') {
        if (chunk.length < 2 || chunk[0] !== 0x05 || chunk[1] !== 0x00) {
          return fail(new Error('SOCKS5CONNECT失败(节点这条链路实际走不通)'));
        }
        return succeed();
      }
    });
  });
}

// 在一条隧道(可能是原始 tcp,也可能是套了 tls 的)上发一段 HTTP/1.1 请求,
// 读完整个响应体(靠 Connection: close 或 Content-Length 判断结束),返回状态码/字节数/耗时。
function httpOverSocket(socket, { hostHeader, requestPath, timeoutMs, method }) {
  return new Promise((resolve, reject) => {
    const startedAt = Date.now();
    let responseChunks = [];
    let totalBytes = 0;
    let headerParsed = false;
    let statusCode = null;
    let contentLength = null;
    let headerEndIdx = -1;
    let settled = false;

    const fail = (err) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      socket.removeAllListeners();
      socket.destroy();
      reject(err);
    };
    const succeed = () => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      socket.removeAllListeners();
      socket.destroy();
      resolve({ statusCode, bodyLength: totalBytes, elapsedMs: Date.now() - startedAt });
    };

    const timer = setTimeout(() => {
      // 测速场景下,即使没读满 Content-Length,超时时已经收到的字节数也是有效的
      // 吞吐量样本——只要已经拿到状态行,就按超时时刻的进度算一次"部分成功"。
      if (statusCode !== null) return succeed();
      fail(new Error('HTTP请求超时'));
    }, timeoutMs);

    socket.on('error', (err) => fail(err));
    socket.on('close', () => {
      if (statusCode !== null) return succeed();
      fail(new Error('连接被对端关闭,且没有收到任何HTTP响应'));
    });

    socket.on('data', (chunk) => {
      totalBytes += chunk.length;
      if (!headerParsed) {
        responseChunks.push(chunk);
        const buf = Buffer.concat(responseChunks);
        const idx = buf.indexOf('\r\n\r\n');
        if (idx !== -1) {
          headerParsed = true;
          headerEndIdx = idx;
          const headerText = buf.slice(0, idx).toString('latin1');
          const statusLine = headerText.split('\r\n')[0];
          const m = statusLine.match(/^HTTP\/1\.[01] (\d{3})/);
          if (!m) return fail(new Error('目标返回了非预期格式的响应:' + statusLine.slice(0, 100)));
          statusCode = Number(m[1]);
          const clMatch = headerText.match(/content-length:\s*(\d+)/i);
          if (clMatch) contentLength = Number(clMatch[1]);
          totalBytes = buf.length - idx - 4; // 只统计 body 部分,不算 header 长度
          if (contentLength !== null && totalBytes >= contentLength) return succeed();
        }
        return;
      }
      if (contentLength !== null && totalBytes >= contentLength) return succeed();
    });

    // Referer 头:speed.cloudflare.com 对 bytes>=1e8(约100MB)的请求会做来源校验,
    // 缺少 Referer 的非浏览器客户端(curl/wget/本探测器等)会被直接返回403
    // (参考社区同类项目 subs-check 的踩坑记录)。当前默认测速量(500KB)远低于这个
    // 门槛所以还没触发,但这里提前把 Referer 带上,以后调大测速量也不会突然失败。
    const req = `${method || 'GET'} ${requestPath} HTTP/1.1\r\nHost: ${hostHeader}\r\nConnection: close\r\nUser-Agent: NodeNanny-PoolChecker\r\nReferer: https://${hostHeader}/\r\nAccept: */*\r\n\r\n`;
    socket.write(req);
  });
}

// 主入口:通过本机 SOCKS5 出口,请求一个 http:// 或 https:// 的 URL。
// 返回 { statusCode, bodyLength, elapsedMs }。
// https 目标会在 SOCKS5 隧道上再套一层 tls.connect,insecure=true 时不校验证书链
// (陌生节点后面的目标站点证书链条正常情况下没问题,这里给个开关避免个别边缘情况误报)。
async function socksFetch({ socksHost, socksPort, url, timeoutMs, connectTimeoutMs, insecureTls, method }) {
  const target = new URL(url);
  const isHttps = target.protocol === 'https:';
  const targetPort = Number(target.port) || (isHttps ? 443 : 80);
  const requestPath = (target.pathname || '/') + (target.search || '');

  const rawSocket = await socksConnect({
    socksHost,
    socksPort,
    targetHost: target.hostname,
    targetPort,
    connectTimeoutMs: connectTimeoutMs || 8000
  });

  if (!isHttps) {
    return httpOverSocket(rawSocket, { hostHeader: target.hostname, requestPath, timeoutMs, method });
  }

  return new Promise((resolve, reject) => {
    const tlsSocket = tls.connect({
      socket: rawSocket,
      servername: target.hostname,
      rejectUnauthorized: !insecureTls
    });
    const timer = setTimeout(() => {
      tlsSocket.destroy();
      reject(new Error('TLS握手超时'));
    }, connectTimeoutMs || 8000);
    tlsSocket.once('secureConnect', () => {
      clearTimeout(timer);
      httpOverSocket(tlsSocket, { hostHeader: target.hostname, requestPath, timeoutMs, method }).then(resolve, reject);
    });
    tlsSocket.once('error', (err) => {
      clearTimeout(timer);
      reject(new Error('TLS握手失败:' + err.message));
    });
  });
}

module.exports = { socksFetch, _internal: { socksConnect, httpOverSocket } };