手册目录 / 服务器与节点

观测位置、自有节点与代理

#观测位置

探测从多个位置的节点发出。平台共享节点已经覆盖了常见地区;需要「从我自己的网络看」时,可以加自有节点或代理。

#自有抓取节点

抓取节点是一个跑在你自己服务器上的代抓端点:平台把要探测的网址发给它,它取回页面原文交回平台——解析与判定仍在平台完成,节点只负责取回内容。适合:监控内网、从自己机房看外网、固定来源 IP 的合规要求。

#技术要点

#部署步骤

  • 保存节点文件 展开下面的折叠块,点代码块上的「复制」,全文保存为 fetch-node.php。
  • 生成令牌 把文件里的 REPLACE_ME_WITH_A_LONG_RANDOM_TOKEN 换成你自己的随机串(至少 16 位)。有 PHP 环境的话可直接生成:php -r "echo bin2hex(random_bytes(24));"
  • 上传并配置 HTTPS 传到自有服务器某个 PHP 站点的目录,例如 https://node.example.com/fetch-node.php。
  • 登记并测试 在 节点与代理 添加观测位置:类型选「抓取节点」,填节点地址与同一个令牌,保存后点 测试 验证连通。
节点文件 fetch-node.php(全文)
<?php
/**
 * WebCheck 抓取节点(单文件,无依赖,不需要数据库)
 *
 * 用途:主系统所在网络访问不了某些站点(例如国内访问不了海外站),
 *      把这个文件放到一台能访问目标站的服务器(如香港服务器)上,
 *      主系统通过它代抓页面。解析与判定仍在主系统完成,节点只负责取原文。
 *
 * ── 部署 ──────────────────────────────────────────────
 * 1. 上传到香港服务器的任意站点目录,例如
 *      /www/wwwroot/hk.example.com/fetch-node.php
 * 2. 修改下面的 TOKEN 为一串长随机字符串(和主系统里填的必须一致):
 *      php -r "echo bin2hex(random_bytes(24));"
 * 3. 建议给该站点配上 HTTPS
 * 4. 在主系统「系统设置 → 抓取通道」里填:
 *      节点地址 https://hk.example.com/fetch-node.php
 *      节点令牌 与下面 TOKEN 相同
 * 5. 需要抓取的监控项,把「抓取通道」设为「抓取节点」
 *
 * ── 安全说明 ──────────────────────────────────────────
 * 这是一个"代抓"接口,如果不设防会变成 SSRF 跳板(别人可以借你的服务器
 * 扫内网、探测云元数据接口)。因此本文件做了以下限制:
 *   - 必须携带 HMAC 签名(令牌本身不在网络上传输)
 *   - 请求带时间戳,超过 5 分钟视为重放攻击
 *   - 只允许 http/https
 *   - 解析域名后拒绝内网地址(127./10./172.16-31./192.168./169.254/::1 等)
 *   - 跟随跳转时逐跳复查,防止用跳转绕过内网检查
 *   - 限制响应体积与超时
 * 如需进一步收紧,把 ALLOW_HOSTS 填成你实际要抓的域名白名单。
 */

// ============ 配置 ============
// 注意:这是模板文件,会进版本库、也内嵌在帮助手册里公开。
//       真实令牌只填在服务器上部署的那一份,这里保持占位符。
const TOKEN       = 'REPLACE_ME_WITH_A_LONG_RANDOM_TOKEN';
const MAX_BODY    = 3145728;   // 3MB
const MAX_TIMEOUT = 60;
const SIGN_TTL    = 300;       // 签名有效期(秒)
// 留空 = 允许任意公网域名;填了则只允许这些域名(含子域)
const ALLOW_HOSTS = [];        // 例:['faststone.org', 'example.com']
// 服务器 CA 库过旧、抓 HTTPS 报 curl(60) 时,下载 https://curl.se/ca/cacert.pem 并填绝对路径
const CACERT      = '';
// ==============================

header('Content-Type: application/json; charset=utf-8');

function out(array $data, $code = 200)
{
    http_response_code($code);
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
    exit;
}

if (TOKEN === 'REPLACE_ME_WITH_A_LONG_RANDOM_TOKEN' || strlen(TOKEN) < 16) {
    out(['ok' => false, 'error' => '节点尚未配置 TOKEN,请编辑 fetch-node.php'], 500);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    out(['ok' => false, 'error' => '仅支持 POST'], 405);
}

$raw  = file_get_contents('php://input');
$sign = isset($_SERVER['HTTP_X_WEBCHECK_SIGN']) ? $_SERVER['HTTP_X_WEBCHECK_SIGN'] : '';
if ($sign === '' || !hash_equals(hash_hmac('sha256', $raw, TOKEN), $sign)) {
    out(['ok' => false, 'error' => '签名校验失败'], 403);
}

$req = json_decode($raw, true);
if (!is_array($req) || empty($req['url'])) {
    out(['ok' => false, 'error' => '请求体格式错误'], 400);
}
$ts = isset($req['ts']) ? (int) $req['ts'] : 0;
if (abs(time() - $ts) > SIGN_TTL) {
    out(['ok' => false, 'error' => '请求已过期(检查两台服务器的时间是否同步)'], 403);
}

$url = trim((string) $req['url']);
$err = checkUrl($url);
if ($err !== '') {
    out(['ok' => false, 'error' => $err], 400);
}

/** 校验 URL 是否允许抓取:协议、域名白名单、内网地址 */
function checkUrl($url)
{
    $p = parse_url($url);
    if (!$p || empty($p['scheme']) || empty($p['host'])) {
        return 'URL 格式错误';
    }
    if (!in_array(strtolower($p['scheme']), ['http', 'https'], true)) {
        return '只允许 http/https';
    }
    $host = strtolower($p['host']);

    if (ALLOW_HOSTS) {
        $hit = false;
        foreach (ALLOW_HOSTS as $allow) {
            $allow = strtolower(trim($allow));
            if ($host === $allow || substr($host, -strlen('.' . $allow)) === '.' . $allow) {
                $hit = true;
                break;
            }
        }
        if (!$hit) {
            return '域名 ' . $host . ' 不在节点的白名单内';
        }
    }

    // 直接写 IP 的情况单独判,否则会落到"域名解析失败"这种误导性的提示上
    if (filter_var($host, FILTER_VALIDATE_IP)) {
        return filter_var($host, FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)
            ? '' : '拒绝抓取内网/保留地址(' . $host . ')';
    }

    // 解析出的所有 IP 都必须是公网地址,否则可能被用来扫内网
    $ips = [];
    $recs = @dns_get_record($host, DNS_A + DNS_AAAA);
    if (is_array($recs)) {
        foreach ($recs as $r) {
            if (!empty($r['ip'])) {
                $ips[] = $r['ip'];
            }
            if (!empty($r['ipv6'])) {
                $ips[] = $r['ipv6'];
            }
        }
    }
    if (!$ips) {
        $ip = gethostbyname($host);
        if ($ip !== $host) {
            $ips[] = $ip;
        }
    }
    if (!$ips) {
        return '域名解析失败:' . $host;
    }
    foreach ($ips as $ip) {
        if (!filter_var($ip, FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            return '拒绝抓取内网/保留地址(' . $ip . ')';
        }
    }
    return '';
}

/**
 * 从句柄上取耗时四段 + 总耗时,供主控做多地区测速对比。
 *
 * 数据全部来自这次抓取已经产生的 curl 计时,**不额外发任何请求**——
 * 这是和主控 Checker::connInfo() 一致的硬约束。
 *
 * 四段是可加的:dns + tcp + tls + ttfb + 内容传输 ≈ total。
 * 本文件手动逐跳跟随跳转,所以这些值是**最后一跳**的,之前的跳转体现在 hops 上。
 *
 * 注意:必须在 curl_close() 之前调用;appconnect_time 也不在 curl_getinfo()
 * 的数组里,要用常量单独取。
 */
function timings($ch)
{
    $ms = function ($v) {
        return $v === null || $v <= 0 ? null : (int) round($v * 1000);
    };
    $dns     = (float) curl_getinfo($ch, CURLINFO_NAMELOOKUP_TIME);
    $connect = (float) curl_getinfo($ch, CURLINFO_CONNECT_TIME);
    $app     = defined('CURLINFO_APPCONNECT_TIME')
        ? (float) curl_getinfo($ch, CURLINFO_APPCONNECT_TIME) : 0.0;
    $pre     = (float) curl_getinfo($ch, CURLINFO_PRETRANSFER_TIME);
    $start   = (float) curl_getinfo($ch, CURLINFO_STARTTRANSFER_TIME);
    $total   = (float) curl_getinfo($ch, CURLINFO_TOTAL_TIME);

    // 每一段都可能因为连接在更早阶段就失败而为 0,相减前先确认上一段真的完成了
    return [
        'dns_ms'     => $ms($dns),
        'tcp_ms'     => $ms($connect > 0 && $dns > 0 ? $connect - $dns : null),
        'tls_ms'     => $ms($app > 0 && $connect > 0 ? $app - $connect : null),
        'ttfb_ms'    => $ms($start > 0 && $pre > 0 ? $start - $pre : null),
        'total_ms'   => $ms($total),
        'size_bytes' => (int) curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD),
        // 2=HTTP/1.1,3=HTTP/2(这个枚举很容易搞反)
        'http_ver'   => defined('CURLINFO_HTTP_VERSION')
            ? (int) curl_getinfo($ch, CURLINFO_HTTP_VERSION) : null,
    ];
}

/**
 * 叶子证书信息。
 *
 * 为什么节点要管这个:主控把某个站点设成"走节点探测",正是因为它自己**根本连不上**
 * 那个站。连不上就完不成 TLS 握手,也就永远拿不到证书——那些站点的证书到期提醒
 * 会静默失效,等到期了才发现。节点是唯一能看到证书的位置,必须由它回传。
 *
 * 同样不额外发请求:CURLOPT_CERTINFO 让本次握手顺带带回来而已。
 * 只回传原始字符串,**不在这里算剩余天数**——节点和主控的时钟可能不同步,
 * 天数一律由主控用自己的时钟算。
 */
function certinfo($ch)
{
    $ci = curl_getinfo($ch, CURLINFO_CERTINFO);
    if (empty($ci) || !is_array($ci) || empty($ci[0])) {
        return null;
    }
    $leaf = $ci[0];
    $exp  = '';
    foreach (['Expire date', 'Expire Date', 'expire date'] as $k) {
        if (!empty($leaf[$k])) {
            $exp = (string) $leaf[$k];
            break;
        }
    }
    if ($exp === '') {
        return null;
    }
    return [
        'expire_date' => $exp,
        'issuer'      => isset($leaf['Issuer']) ? substr($leaf['Issuer'], 0, 250) : '',
        'subject'     => isset($leaf['Subject']) ? substr($leaf['Subject'], 0, 250) : '',
    ];
}

$timeout = min(MAX_TIMEOUT, max(5, isset($req['timeout']) ? (int) $req['timeout'] : 25));
$headers = ['Accept: text/html,application/xhtml+xml,application/json,*/*;q=0.8'];
$lang    = isset($req['accept_language']) ? trim((string) $req['accept_language']) : '';
$headers[] = 'Accept-Language: ' . ($lang !== '' ? $lang : 'zh-CN,zh;q=0.9,en;q=0.8');
foreach (preg_split('/\r\n|\r|\n/', isset($req['headers']) ? (string) $req['headers'] : '') as $h) {
    $h = trim($h);
    // 不允许覆盖 Host,避免被用来做域前置
    if ($h !== '' && strpos($h, ':') !== false && stripos($h, 'host:') !== 0) {
        $headers[] = $h;
    }
}
$ua = isset($req['user_agent']) ? trim((string) $req['user_agent']) : '';
if ($ua === '') {
    $ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
        . '(KHTML, like Gecko) Chrome/124.0 Safari/537.36';
}

$t0 = microtime(true);
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => false,   // 自己控制跳转,逐跳复查目标地址
    CURLOPT_CONNECTTIMEOUT => min(20, $timeout),
    CURLOPT_TIMEOUT        => $timeout,
    CURLOPT_USERAGENT      => $ua,
    CURLOPT_ENCODING       => '',
    CURLOPT_HTTPHEADER     => $headers,
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,
    // 顺带把证书带回来,无需二次握手。主控对"走节点探测"的站点没有别的办法
    // 拿到证书到期时间(它自己连不上那个站)
    CURLOPT_CERTINFO       => true,
]);
if (CACERT !== '' && is_file(CACERT)) {
    curl_setopt($ch, CURLOPT_CAINFO, CACERT);
}

$body = '';
$code = 0;
$type = '';
$hops = 0;
$peerIp = '';
$peerPort = 0;
$current = $url;
while (true) {
    curl_setopt($ch, CURLOPT_URL, $current);
    $body = curl_exec($ch);
    // 节点这一侧实际连上的 IP。回传给主控后,就能对比「监控机解析到 A、
    // 这个节点解析到 B」——排查 CDN 调度和 DNS 污染时这是最直接的证据。
    // 每一跳都覆盖,循环结束时留下的自然是最终那一跳的值。
    $ip = (string) curl_getinfo($ch, CURLINFO_PRIMARY_IP);
    if (filter_var($ip, FILTER_VALIDATE_IP)) {
        $peerIp   = $ip;
        $peerPort = (int) curl_getinfo($ch, CURLINFO_PRIMARY_PORT);
    }
    if (curl_errno($ch) !== 0) {
        $e = curl_error($ch);
        $n = curl_errno($ch);
        // 失败时的分段耗时同样有价值:能看出是卡在 DNS、握手还是等响应
        $tm = timings($ch);
        curl_close($ch);
        out(array_merge($tm, [
            'ok' => false, 'error' => '抓取失败 curl(' . $n . '): ' . $e,
            'peer_ip' => $peerIp, 'peer_port' => $peerPort ?: null,
            'latency_ms' => (int) round((microtime(true) - $t0) * 1000),
        ]));
    }
    $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);

    /* 运行时兜底:checkUrl 查的是"解析到哪",拦不住 DNS 重绑定——
       实际连上内网/保留地址时立即作废本次响应(内容不回传主控)。 */
    if ($peerIp !== '' && !filter_var($peerIp, FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
        curl_close($ch);
        out(['ok' => false, 'error' => '实际连接到内网/保留地址(' . $peerIp . '),响应已作废',
             'peer_ip' => $peerIp]);
    }

    if (!in_array($code, [301, 302, 303, 307, 308], true) || $hops >= 5) {
        break;
    }
    $loc = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL);
    if ($loc === '') {
        break;
    }
    $err = checkUrl($loc);
    if ($err !== '') {
        curl_close($ch);
        out(['ok' => false, 'error' => '跳转目标被拒绝:' . $err]);
    }
    $current = $loc;
    $hops++;
}
$tm   = timings($ch);
$cert = certinfo($ch);
curl_close($ch);

if (strlen((string) $body) > MAX_BODY) {
    $body = substr($body, 0, MAX_BODY);
}
// 测速场景只要计时不要正文:页面照常完整下载(否则 total_ms 就不含内容传输、
// 测出来的速度是假的),但不回传给主控,省掉每次几百 KB 的跨境回程流量。
if (!empty($req['no_body'])) {
    $body = '';
}

out(array_merge($tm, [
    'ok'           => true,
    'http_code'    => $code,
    'content_type' => $type,
    'final_url'    => $current,
    'hops'         => $hops,
    'body'         => (string) $body,
    // 叶子证书原文(主控用自己的时钟算剩余天数),老主控会忽略这个键
    'cert'         => $cert,
    // 节点视角看到的服务器 IP,供主控做多点解析对比
    'peer_ip'      => $peerIp,
    'peer_port'    => $peerPort ?: null,
    'latency_ms'   => (int) round((microtime(true) - $t0) * 1000),
    'node'         => php_uname('n'),
]));
部署后第一件事:确认源码没被原样下载

节点所在站点必须启用 PHP。如果站点是纯静态,访问该 URL 会把文件源码(含令牌)直接下载下来,等于把令牌贴在公网。部署后先用浏览器访问一次节点地址:正常应返回一条 JSON 报错「仅支持 POST」;如果看到一屏 PHP 代码,立即删掉文件、给站点选上 PHP 版本、换新令牌重来。

不会配服务器?把下面这段提示词整段复制发给任意 AI 助手,把方括号里的内容换成你的实际情况,并在末尾附上上面的节点文件全文:

我有一个 PHP 单文件 fetch-node.php(网站监控平台的抓取节点程序,全文附在最后),
需要部署到我自己的服务器上。

我的环境:[宝塔面板 + Debian 12,站点 node.example.com]

请帮我:
1. 把文件放进站点目录,并确认站点启用了 PHP——不能是"纯静态"站点,
   否则文件源码会被直接下载,里面的令牌会泄露;
2. 把文件里的 TOKEN 常量改为我生成的随机字符串;
3. 确认该站点已配置 HTTPS 证书;
4. 如可行,用防火墙或访问控制把这个 URL 的来源限制为只允许指定 IP;
5. 给我一条 curl 命令做部署验证:正常应返回 JSON 且 HTTP 405(节点只接受 POST)。

约束:该节点只用于监控我自己管理的网站,不开放给第三方使用。

#代理通道

在站点/监控的「抓取通道」里填代理地址,探测走你的代理出去。支持的写法:

写法说明
http://host:portHTTP / HTTPS 代理
http://user:pass@host:port带认证的 HTTP 代理
socks5://host:portSOCKS5,域名在本机解析
socks5h://host:portSOCKS5,域名交给代理解析(推荐,避免本地解析被污染)

SSH 动态转发(ssh -D 1080)也可以当 SOCKS5 用,填 socks5h://127.0.0.1:1080。

适合:目标拦了平台节点的 IP、需要固定的出口 IP、或走公司专线/特定网络。

还没有代理?把下面这段发给 AI 助手,让它按你的服务器环境给出部署命令:

请在我自己的服务器上搭建一个仅供我本人使用的代理,供网站监控平台
从该网络位置发起 HTTP 探测。

我的环境:[Debian 12,有 root 权限,云服务商安全组可控]

要求:
1. 优先 SOCKS5;如果推荐 HTTP 代理,请说明理由;
2. 必须启用用户名密码认证,绝不搭开放代理;
3. 防火墙或安全组只放行我需要使用的来源 IP;
4. 给出开机自启/进程保活配置,以及一条 curl --proxy 验证命令;
5. 告诉我最终在监控平台里应该填写的地址格式(scheme://user:pass@host:port)。

约束:仅用于监控我自己管理的网站,不提供给任何第三方使用。
注意

走代理的探测,目标看到的是代理的 IP 不是平台的——代理的稳定性直接影响你的监控。先在 节点与代理 里测试再投入使用;代理务必开启认证并收窄来源,开放代理会被全网扫描滥用。

#使用边界

说明
  • 节点与代理只能部署在你自有或获授权的服务器与网络上,仅用于监控你有权监控的目标;
  • 不要把节点或代理开放给第三方使用,不要作为公共转发服务运营;
  • 涉及跨境、专线等部署形态时,请自行确认符合所在地区的法律法规与服务商条款,必要时咨询专业人士。

相关:「我能打开但被判故障」 · 目标被禁止

← 服务器与节点