码桶

发现社区成员的开源项目

main
NodeNanny/core/usability-check.js
usability-check.js17.2 KB
'use strict';

// 二层"真实可用性检测":TCP 端口通≠代理真的能用(进程健在但被墙、或握手失败这类情况,
// 纯端口探测完全看不出来)。这里参考 kutovoys/xray-checker 的思路:
// 不去猜"能不能连 127.0.0.1:检测端口",而是真的临时拉起一个 xray 客户端进程
// (复用系统已装好的 xray 二进制,不新增任何 npm 依赖),让它作为一个本机 SOCKS5
// 出口,通过它去请求一个外部地址,看能不能拿到预期响应。
//
// 范围说明(如实写在这里,别指望这个模块万能):
// - 只支持 vless + reality(parseVlessReality 解析不出来就直接跳过,返回 unsupported)。
// - 依赖 config.node.subscriptionUrl 有值(批次一已经打通这条链路,装机走过 233boy 流程就会有)。
// - 依赖系统 PATH 或指定路径下能找到 xray 可执行文件,找不到就报 check_error,不是 down。
// - 外部探测目标本身抽风(比如 gstatic 被临时墙/网络波动)时,应该报 check_error 而不是 down,
//   避免把"探测手段失效"误判成"节点故障",这一点在下面 runUsabilityCheck 里专门做了区分。

const net = require('net');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { parseVlessReality } = require('./vless-parse');

// v21修复(跟pool-checker.js的alive层是同一个思路,这次轮到主节点自己的可用性检测):
// 默认探测目标此前是Google的gstatic(还是明文http)。创始人反馈过主节点的这项检测
// 会间歇性"未通过",跟流量池那次"旺财测不出"是同一类问题——出口网络对Google系
// 服务的连通性天生就不如对Cloudflare稳定,不是节点真的时好时坏。换成Cloudflare的
// 端点,跟pool-checker.js里已经验证过可行的目标保持一致。仍然可以通过
// config.json的usabilityCheck.targetUrl覆盖,不强制。
//
// 2026-07-23修复:v21把目标换成Cloudflare时误写成了https://,但下面
// runUsabilityCheck里的探测器是手写的最小化SOCKS+HTTP实现,本身没有实现TLS
// 握手,遇到https://目标一律判USABILITY_TARGET_MUST_BE_HTTP(check_error)。
// 也就是说没手动覆盖targetUrl的话,二层检测从v21起就一直在报这个配置错误,
// 从未真正探测过。cp.cloudflare.com本身同时支持明文http和https(实测http也
// 返回204),这里只改协议,不换目标,维持原来"避开Google系连通性问题"的决策。
// 如果之后发现这个host本身不稳定(社区里零星有反馈),可以考虑换成同样支持明文
// http+204的备选:http://www.msftconnecttest.com/connecttest.txt 或
// http://detectportal.firefox.com/success.txt。
const DEFAULT_TARGET = 'http://cp.cloudflare.com/generate_204';
const DEFAULT_XRAY_BINARY = 'xray';
const DEFAULT_TIMEOUT_MS = 8000;

// 找一个当前空闲的本机端口,给临时 xray 客户端的 socks 入站用。
function getFreePort() {
  return new Promise((resolve, reject) => {
    const server = net.createServer();
    server.unref();
    server.on('error', reject);
    server.listen(0, '127.0.0.1', () => {
      const { port } = server.address();
      server.close(() => resolve(port));
    });
  });
}

// 反复尝试连接,直到 xray 客户端进程把 socks 端口开起来,或者超时。
function waitForPort(host, port, timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  return new Promise((resolve) => {
    const tryOnce = () => {
      const socket = new net.Socket();
      let settled = false;
      const done = (ok) => {
        if (settled) return;
        settled = true;
        socket.destroy();
        if (ok) return resolve(true);
        if (Date.now() >= deadline) return resolve(false);
        setTimeout(tryOnce, 150);
      };
      socket.setTimeout(500);
      socket.once('connect', () => done(true));
      socket.once('timeout', () => done(false));
      socket.once('error', () => done(false));
      socket.connect(port, host);
    };
    tryOnce();
  });
}

function buildXrayClientConfig(vlessParams, socksPort) {
  return {
    log: { loglevel: 'warning' },
    inbounds: [
      { listen: '127.0.0.1', port: socksPort, protocol: 'socks', settings: { udp: false, auth: 'noauth' } }
    ],
    outbounds: [
      {
        protocol: 'vless',
        settings: {
          vnext: [
            {
              address: vlessParams.address,
              port: vlessParams.port,
              users: [
                { id: vlessParams.uuid, encryption: vlessParams.encryption || 'none', flow: vlessParams.flow || '' }
              ]
            }
          ]
        },
        streamSettings: {
          network: vlessParams.network || 'tcp',
          security: 'reality',
          realitySettings: vlessParams.realitySettings
        }
      },
      { protocol: 'freedom', tag: 'direct' }
    ]
  };
}

// 手写一个最小化的 SOCKS5 客户端握手(CONNECT,无认证),换取一条到目标地址的隧道,
// 之后在同一条 TCP 连接上直接发一段裸的 HTTP/1.1 请求。不引入 socks/http-proxy 之类的
// npm 依赖——协议本身很简单,没必要为这几十行手写代码加一个包。
function socksHttpProbe({ socksHost, socksPort, targetHost, targetPort, requestPath, timeoutMs }) {
  return new Promise((resolve, reject) => {
    const socket = net.connect(socksPort, socksHost);
    let stage = 'greeting';
    let responseChunks = [];
    let settled = false;

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

    // 2026-07-15修复:原来不管卡在哪个阶段,超时都抛同一句"探测请求超时",调用方
    // 只能笼统地把它当成check_error(检测手段本身的问题)处理。但跟pool-checker.js里
    // 修过的同类问题(SOCKS5连接超时/TLS握手超时按阶段区分down还是check_error)是同一种性质:
    // greeting阶段超时,是本机xray客户端自己没反应,才算真的是检测设施问题;
    // connect/http阶段超时,说明本机到xray客户端这段已经通了,是远程节点这条链路
    // 没能在合理时间内连上目标或者响应,应该判定节点真的不可用。这里带上阶段标记,
    // 具体分类逻辑在下面 runUsabilityCheck 的 catch 分支里处理。
    const timer = setTimeout(() => fail(new Error(`Probe timed out [stage:${stage}]`)), timeoutMs);

    socket.on('error', (err) => fail(err));

    socket.on('connect', () => {
      // SOCKS5 greeting:版本5,1 种认证方式,无需认证。
      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 handshake failed (xray client may not have started, or config mismatch)'));
        }
        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('SOCKS5 CONNECT failed, proxy could not reach the target address (likely the node is actually down)'));
        }
        stage = 'http';
        const req = `GET ${requestPath} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\nUser-Agent: NodeNanny-UsabilityCheck\r\n\r\n`;
        socket.write(req);
        return;
      }
      // stage === 'http':攒 HTTP 响应,只需要状态行就够判断了。
      responseChunks.push(chunk);
      const buf = Buffer.concat(responseChunks).toString('latin1');
      const lineEnd = buf.indexOf('\r\n');
      if (lineEnd !== -1) {
        const statusLine = buf.slice(0, lineEnd);
        const m = statusLine.match(/^HTTP\/1\.[01] (\d{3})/);
        if (m) {
          succeed(Number(m[1]));
        } else {
          fail(new Error('Target returned an unexpected response format: ' + statusLine.slice(0, 100)));
        }
      }
    });
  });
}

function killProcess(child) {
  if (!child || child.killed) return;
  try {
    // 发现29根因:/usr/local/bin/xray 在部分安装方式(233boy 脚本)下是一个 source 调用
    // 内层真实二进制的 bash 包装脚本,不是 exec 调用。SIGTERM 发给 child.pid 只会杀死
    // 这层外壳 bash,内层真正的 xray 子进程杀不掉,逐次检测都会漏杀一个,长期运行会
    // 把小内存服务器的内存耗尽(真机上曾观测到306个孤儿xray进程、内存占用98%+)。
    // 修法:spawn 时加 detached:true 把子进程放进独立进程组,这里用负数 pid
    // (process.kill(-pid, sig)是杀整个进程组的标准写法)连壳带内层子进程一起杀掉;
    // 如果拿不到 pid(理论上不该发生)就退回杀 child 本身,保底不报错。
    if (child.pid) {
      process.kill(-child.pid, 'SIGTERM');
    } else {
      child.kill('SIGTERM');
    }
  } catch (err) {
    // 忽略,进程可能已经退出
  }
}

// 把 socksHttpProbe 抛出的错误分类成 'down' 或 'check_error'。
// 2026-07-15新增(此前这段逻辑内联在 runUsabilityCheck 的 catch 里,只认 "CONNECT 失败"
// 这一种情况,没有区分超时发生在哪个阶段,见上面 socksHttpProbe 里的修复说明)。
function classifyUsabilityError(err) {
  const msg = err && err.message ? err.message : String(err);
  const stageMatch = msg.match(/Probe timed out \[stage:(\w+)\]/);
  if (stageMatch) {
    // greeting阶段超时 = 本机xray客户端没反应,是检测设施问题;
    // connect/http阶段超时 = 隧道已经往前走了一段,是节点/目标那头的问题。
    return stageMatch[1] === 'greeting' ? 'check_error' : 'down';
  }
  if (/CONNECT failed/.test(msg)) return 'down';
  return 'check_error'; // 兜底:含糊不清的错误,宁可判"检测本身有问题"也不错杀节点
}

// code重构第一批(usability-check.js):给classifyUsabilityError覆盖的同一批错误
// 补一个稳定的英文code,跟上面classifyUsabilityError分开写、不改它的返回值,
// 避免动到已有的test-usability-classify.js(那边断言的是裸字符串'down'/'check_error')。
// 这个函数只负责"这条错误具体是哪一种",不负责outcome判定,两者在调用处一起挂到
// 返回对象上。以后知识库要按code精确匹配detail时,就从这里取,不用再猜正则。
function classifyUsabilityErrorCode(err) {
  const msg = err && err.message ? err.message : String(err);
  const stageMatch = msg.match(/Probe timed out \[stage:(\w+)\]/);
  if (stageMatch) {
    const stage = stageMatch[1];
    if (stage === 'greeting') return 'USABILITY_PROBE_TIMEOUT_GREETING';
    if (stage === 'connect') return 'USABILITY_PROBE_TIMEOUT_CONNECT';
    if (stage === 'http') return 'USABILITY_PROBE_TIMEOUT_HTTP';
    return 'USABILITY_PROBE_TIMEOUT_UNKNOWN_STAGE';
  }
  if (/Probe timed out/.test(msg)) return 'USABILITY_PROBE_TIMEOUT_LEGACY'; // 没带阶段标记的旧格式(理论上不会再出现,兜底保留)
  if (/CONNECT failed/.test(msg)) return 'USABILITY_PROBE_SOCKS_CONNECT_FAILED';
  if (/handshake failed/.test(msg)) return 'USABILITY_PROBE_SOCKS_HANDSHAKE_FAILED';
  if (/unexpected response format/.test(msg)) return 'USABILITY_PROBE_BAD_RESPONSE_FORMAT';
  return 'USABILITY_PROBE_UNKNOWN_ERROR';
}

// 主入口:给定节点配置 + 已经解析出来的 vless 订阅链接,跑一次完整的二层检测。
// 返回值统一是 { outcome, detail },outcome 取值:
//   'ok'           —— 真的通过代理拿到了预期的外部响应
//   'down'         —— 代理隧道建立了,但请求没拿到预期结果(判定为节点真的不可用)
//   'check_error'  —— 检测手段本身出问题了(xray 二进制缺失、进程没起来、外部探测目标超时等),
//                     不代表节点故障,调用方不应该据此触发重启/通知
//   'unsupported'  —— 当前订阅链接不是 vless+reality,或者完全没有订阅链接,本版本跳过二层检测
async function runUsabilityCheck(config) {
  const node = config.node || {};
  const opts = config.usabilityCheck || {};
  if (opts.enabled === false) {
    return { outcome: 'unsupported', code: 'USABILITY_DISABLED', detail: 'Layer-2 usability check is disabled' };
  }

  const vlessParams = parseVlessReality(node.subscriptionUrl);
  if (!vlessParams) {
    return { outcome: 'unsupported', code: 'USABILITY_UNSUPPORTED_PROTOCOL', detail: 'Subscription link is empty, or not vless+reality (this version only supports that combination)' };
  }

  const targetUrl = (() => {
    try {
      return new URL(opts.targetUrl || DEFAULT_TARGET);
    } catch (err) {
      return new URL(DEFAULT_TARGET);
    }
  })();
  const targetPort = Number(targetUrl.port) || (targetUrl.protocol === 'https:' ? 443 : 80);
  if (targetUrl.protocol === 'https:') {
    // 手写的探测器只实现了明文 HTTP(避免还要在 SOCKS 隧道里再手搓一层 TLS),
    // 所以强制要求探测目标是 http://,配置了 https:// 直接当配置错误处理。
    return { outcome: 'check_error', code: 'USABILITY_TARGET_MUST_BE_HTTP', detail: 'Probe target must be an http:// address (this version cannot do a TLS handshake inside the tunnel)' };
  }

  const xrayBinary = opts.xrayBinary || DEFAULT_XRAY_BINARY;
  const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;

  let socksPort;
  try {
    socksPort = await getFreePort();
  } catch (err) {
    return { outcome: 'check_error', code: 'USABILITY_NO_FREE_PORT', detail: 'Could not find a free local port for the temporary xray client: ' + err.message };
  }

  const tmpConfigPath = path.join(os.tmpdir(), `nodenanny-usability-${process.pid}-${socksPort}.json`);
  const xrayConfig = buildXrayClientConfig(vlessParams, socksPort);

  let child;
  try {
    fs.writeFileSync(tmpConfigPath, JSON.stringify(xrayConfig));

    // detached:true 让这个子进程成为独立进程组的组长(pid === 进程组id),
    // 这样即使 xrayBinary 实际是个 source 调用内层二进制的包装脚本,killProcess
    // 用 -pid 杀整个组时也能连内层子进程一起杀掉,见 killProcess 里发现29的说明。
    child = spawn(xrayBinary, ['run', '-config', tmpConfigPath], { stdio: 'ignore', detached: true });
    const spawnError = await new Promise((resolve) => {
      child.once('error', (err) => resolve(err));
      // 没有报错事件的话,给个短暂窗口让 spawn 真正跑起来再继续。
      setTimeout(() => resolve(null), 50);
    });
    if (spawnError) {
      return { outcome: 'check_error', code: 'USABILITY_XRAY_SPAWN_FAILED', detail: `Failed to launch the temporary xray client process (xray may not be installed, or not on PATH): ${spawnError.message}` };
    }

    const portReady = await waitForPort('127.0.0.1', socksPort, Math.min(timeoutMs, 5000));
    if (!portReady) {
      return { outcome: 'check_error', code: 'USABILITY_SOCKS_PORT_NOT_READY', detail: 'The temporary xray client process started, but the local socks port never became reachable (vless/reality params may not match the node\'s actual config)' };
    }

    try {
      const { statusCode } = await socksHttpProbe({
        socksHost: '127.0.0.1',
        socksPort,
        targetHost: targetUrl.hostname,
        targetPort,
        requestPath: targetUrl.pathname + targetUrl.search,
        timeoutMs
      });
      if (statusCode >= 200 && statusCode < 400) {
        return { outcome: 'ok', code: 'USABILITY_OK', detail: `Got an external response through the proxy, status code ${statusCode}` };
      }
      return { outcome: 'down', code: 'USABILITY_BAD_STATUS_CODE', detail: `Requested the external address through the proxy but got status code ${statusCode} — treated as the node actually being down` };
    } catch (err) {
      // outcome判定沿用原classifyUsabilityError(避免动到已有测试),code另外从
      // classifyUsabilityErrorCode取,两者基于同一条err.message分别判断,不冲突。
      return { outcome: classifyUsabilityError(err), code: classifyUsabilityErrorCode(err), detail: err.message };
    }
  } catch (err) {
    return { outcome: 'check_error', code: 'USABILITY_UNEXPECTED_ERROR', detail: 'Unexpected error during the layer-2 check: ' + err.message };
  } finally {
    killProcess(child);
    try {
      fs.unlinkSync(tmpConfigPath);
    } catch (err) {
      // 临时文件删不掉不影响功能,忽略
    }
  }
}

module.exports = {
  runUsabilityCheck,
  // 以下几个仅供本地测试用(比如拿假的 SOCKS5/HTTP 服务器验证手写协议逻辑对不对),
  // 正常运行路径不需要外部直接调用这几个内部函数。
  _internal: { getFreePort, waitForPort, socksHttpProbe, classifyUsabilityError, classifyUsabilityErrorCode }
};