自托管节点聚合订阅服务 (公开节点池+TCP探活+CN-Exit协议级体检+v2rayN/sing-box订阅)

下载 .md

自托管节点聚合订阅服务 SOP

公开免费节点聚合 → TCP 探活 → CN 出口协议级体检 → v2rayN / sing-box 订阅端点。 解决公开节点池"端口通但实际不可用"的核心痛点。

适用人群:自建订阅服务运维、个人节点池聚合、公开节点可用性研究。


0. TL;DR(30 秒概览)

公开订阅源(5+) ── 6h 定时拉取 ──▶ raw URI 池 (~400)
                                    │
                            TCP 探活 (1.5s, 并发 200)
                                    │
                            alive (~340)
                                    │
         ┌──────────────────────────┴──────────────────────────┐
         │                                                      │
     /sub 端点                                       (旁路) CN-Exit 体检
     (base64, v2rayN/Clash 兼容)                              │
         │                                    mihomo /proxies/{}/delay
         │                                    三轮 5s 协议级 healthcheck
         │                                    全 0 → dead
         │                                              │
         │                                    host:port → cn_blacklist.json
         └────────── /sub 出口处过滤 ◀─────────────────┘
                                    │
                            ~190 真正可用节点

核心依赖

  • 一台海外 VPS(SG / JP 延迟低)
  • Python 3.10+ / aiohttp / PyYAML
  • mihomo(原 Clash.Meta)用于协议级体检
  • 可选:sing-box 做多端口分流出口

实测数据(5 源 / 周 1 体检)

  • raw 节点: ~423
  • TCP 探活后: ~341
  • CN 体检剔除: 152 (45%)
  • 最终可用: 189 节点

1. 为什么需要这个方案

1.1 公开节点池的真实困境

问题 表现 传统方案能否解决
短寿命 平均 12-48h 失效,日均 30-60% 死亡 ✅ 定时 TCP 探活
协议假活 TCP 端口开但 TLS / 协议层失效 ⚠️ 需协议级握手
出口不可用 VPS 可达但节点出口被反向封锁 ❌ 无解

VPS TCP 探活验证的是「VPS → 节点 IP:Port 可达」,但用户实际使用的链路是「CN → VPS → 节点 → 目标站」。后者失败原因 90% 在「节点 → 目标站」段:节点 ISP 屏蔽国内站、协议密钥已被吊销但 TCP 端口仍监听、出口被反向封锁。只有让位于真实使用环境的客户端做体检才能复现这种失败

1.2 设计原则

  • 单一职责:聚合 / 探活 / 订阅 / 体检四层完全解耦,可独立替换
  • 失败安全:体检失败时 abort 不动主服务;daemon 重启即热加载缓存避免空窗
  • 零供应商锁定:纯开源工具 + 公开订阅源
  • 最小运维:VPS 端全自动;体检层每周一次(cron / 任务计划)

2. 整体架构

┌─────────────────── VPS (海外) ──────────────────────┐
│                                                     │
│  AGG_SOURCES ──6h抓取──▶ aggregator_fetcher.py     │
│  (5个公开订阅源)               │                    │
│                                ├──▶ pool_data/      │
│                                │    agg_pool.json   │
│                                │    (持久化, ts+uris)│
│                                ▼                    │
│                       TCP 探活 (1.5s, 并发200)      │
│                                │                    │
│                                ▼                    │
│                       cn_blacklist.json 过滤        │
│                                │                    │
│                                ▼                    │
│  aiohttp daemon ── /sub ──▶ base64(URIs)            │
│  (端口 9588)    ── /api/pool ──▶ JSON status        │
│                                                     │
│  (可选) sing-box 多端口分流出口                     │
│                                                     │
└──────────────────────────▲──────────────────────────┘
                           │ scp cn_blacklist.json
                           │
┌────── 本机 (CN 出口, 周一 09:30) ─────────┐
│                                           │
│ cn_health_check.py                        │
│ ├─ ssh 拉 agg_pool.json                   │
│ ├─ 自启 mihomo (端口 19090, file provider)│
│ ├─ 三轮 /proxies/{}/delay 5s             │
│ ├─ 全 3 轮 delay==0 → dead                │
│ ├─ name 反查 URI → host:port              │
│ └─ scp cn_blacklist.json → VPS            │
└───────────────────────────────────────────┘

为什么体检放本机不放 VPS?

VPS 出口在海外,访问国内站的网络路径与 CN 用户完全相反。把体检客户端放在 CN 出口,才能真实代表用户视角。


3. 聚合抓取层

3.1 推荐公开订阅源(2026-05 验证可用)

AGG_SOURCES = [
    "https://raw.githubusercontent.com/peasoft/NoMoreWalls/master/list.txt",
    "https://raw.githubusercontent.com/Barabama/FreeNodes/master/nodes/nodefree.txt",
    "https://raw.githubusercontent.com/Pawdroid/Free-servers/main/sub",
    "https://raw.githubusercontent.com/mfuu/v2ray/master/v2ray",
    "https://raw.githubusercontent.com/ripaojiedian/freenode/main/sub",
]

5 源去重后 raw 总量 400-500。不建议超过 8 源 —— 重复率急剧上升、超时风险增加。 候选扩展源见 wzdnzd/aggregator README。

3.2 多格式源兼容解码

import base64
import urllib.request

PROTO_PREFIX = ("vless://", "vmess://", "ss://", "trojan://", "hysteria2://", "hy2://")

def fetch_source(url: str, timeout: int = 15) -> str:
    """自动识别纯文本 vs base64 整体编码"""
    raw = urllib.request.urlopen(url, timeout=timeout).read().decode("utf-8", "ignore")
    if any(p in raw for p in PROTO_PREFIX):
        return raw
    try:
        decoded = base64.b64decode(raw + "==" * (-len(raw) % 4)).decode("utf-8", "ignore")
        if any(p in decoded for p in PROTO_PREFIX):
            return decoded
    except Exception:
        pass
    return raw

:纯文本源强行 base64 解码会产生乱码 URI 污染池,必须先检测协议前缀。

3.3 URI 端点解析(4 协议覆盖)

import json, re
from urllib.parse import urlparse

def parse_endpoint(uri: str):
    """从 URI 提取 (host, port),失败返回 None"""
    try:
        if uri.startswith("vmess://"):
            payload = base64.b64decode(uri[8:].split("#")[0] + "==").decode()
            d = json.loads(payload)
            return d["add"], int(d["port"])
        u = urlparse(uri.split("#")[0])
        if u.hostname and u.port:
            return u.hostname, u.port
        if uri.startswith("ss://"):
            body = uri[5:].split("#")[0].split("?")[0]
            decoded = base64.b64decode(body + "==").decode()
            m = re.search(r"@([^:]+):(\d+)", decoded)
            if m:
                return m.group(1), int(m.group(2))
    except Exception:
        pass
    return None

3.4 节点重命名(只动 fragment)

import urllib.parse as up

def rename_with_prefix(uri: str, prefix: str = "[AGG]") -> str:
    body, _, frag = uri.partition("#")
    orig = up.unquote(frag) if frag else "node"
    return f"{body}#{up.quote(f'{prefix} {orig}')}"

关键坑:vmess URI 主体是 base64({json}),绝对不能 urlquote,只能改 #fragment。 URL fragment 中 [AGG] 会被 percent-encoded 为 %5BAGG%5D,grep 时注意。---

4. TCP 探活层

4.1 异步端口探测

import asyncio

async def tcp_alive(host: str, port: int, timeout: float = 1.5) -> bool:
    try:
        reader, writer = await asyncio.wait_for(
            asyncio.open_connection(host, port), timeout=timeout
        )
        writer.close()
        try:
            await writer.wait_closed()
        except Exception:
            pass
        return True
    except Exception:
        return False

async def probe_all(uris: list, concurrency: int = 200) -> list:
    sem = asyncio.Semaphore(concurrency)

    async def _one(uri):
        ep = parse_endpoint(uri)
        if not ep:
            return None
        async with sem:
            return uri if await tcp_alive(*ep) else None

    results = await asyncio.gather(*(_one(u) for u in uris))
    return [r for r in results if r]

并发调优:200 并发 × 1.5s → 400 节点约 6 秒。 过大并发(>500)会触发 Linux nf_conntrack 限流,表现为后半段大量 false negative。 调大上限:echo 262144 > /proc/sys/net/netfilter/nf_conntrack_max

4.2 持久化缓存 + 后台刷新

import time, json, logging
from pathlib import Path

CACHE_PATH = Path("pool_data/agg_pool.json")
REFRESH_INTERVAL = 6 * 3600
log = logging.getLogger("aggregator")

async def refresh_loop():
    """daemon 启动即首次刷新 + 每 6h 循环"""
    while True:
        try:
            raw = []
            for src in AGG_SOURCES:
                try:
                    text = fetch_source(src)
                    raw.extend(
                        l.strip() for l in text.splitlines()
                        if l.strip().startswith(PROTO_PREFIX)
                    )
                except Exception as e:
                    log.warning("source fail %s: %s", src, e)

            # host:port 去重
            seen, uniq = set(), []
            for u in raw:
                ep = parse_endpoint(u)
                if not ep or ep in seen:
                    continue
                seen.add(ep)
                uniq.append(rename_with_prefix(u))
            log.info("combined %d unique (pre-probe)", len(uniq))

            alive = await probe_all(uniq)
            log.info("alive after TCP probe: %d", len(alive))

            CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
            CACHE_PATH.write_text(json.dumps({
                "ts": int(time.time()), "uris": alive
            }))
        except Exception as e:
            log.exception("refresh error: %s", e)

        await asyncio.sleep(REFRESH_INTERVAL)

设计要点

  • 源失败只 warn 不中断整体
  • daemon 重启时读 cache 热加载,避免空窗期
  • 后台 task 与 HTTP handler 完全解耦

5. 订阅输出层

5.1 /sub 端点(v2rayN / Clash 通用)

from aiohttp import web

BLACKLIST_PATH = Path("pool_data/cn_blacklist.json")

def get_filtered_lines() -> list:
    """读 cache + 应用 CN 黑名单过滤(每次请求热读)"""
    if not CACHE_PATH.exists():
        return []
    data = json.loads(CACHE_PATH.read_text())
    uris = data.get("uris", [])
    bl = set()
    if BLACKLIST_PATH.exists():
        bl = set(json.loads(BLACKLIST_PATH.read_text()).get("blacklist", []))
    if not bl:
        return uris
    result = []
    for u in uris:
        ep = parse_endpoint(u)
        if ep and f"{ep[0]}:{ep[1]}" in bl:
            continue
        result.append(u)
    return result

async def handle_sub(request):
    lines = get_filtered_lines()
    body = base64.b64encode("\n".join(lines).encode()).decode()
    return web.Response(text=body, content_type="text/plain")

async def handle_status(request):
    lines = get_filtered_lines()
    cache_ts = 0
    if CACHE_PATH.exists():
        cache_ts = json.loads(CACHE_PATH.read_text()).get("ts", 0)
    bl_size = 0
    if BLACKLIST_PATH.exists():
        bl_size = len(json.loads(BLACKLIST_PATH.read_text()).get("blacklist", []))
    return web.json_response({
        "alive_count": len(lines),
        "last_refresh": cache_ts,
        "blacklist_size": bl_size,
    })

客户端添加订阅地址 http://<YOUR_VPS>:9588/sub 即自动导入。

5.2 sing-box 多端口分流(可选进阶)

每地区独立入站端口 + urltest outbound:

{
  "inbounds": [
    {"tag": "auto-in", "type": "vless", "listen_port": 9599,
     "users": [{"uuid": "<YOUR_UUID>"}]},
    {"tag": "US-in",   "type": "vless", "listen_port": 9601,
     "users": [{"uuid": "<YOUR_UUID>"}]}
  ],
  "outbounds": [
    {"tag": "auto", "type": "urltest",
     "outbounds": ["node-1", "node-2"],
     "url": "https://www.gstatic.com/generate_204",
     "interval": "5m"},
    {"tag": "node-1", "type": "vless",
     "server": "1.2.3.4", "server_port": 443}
  ],
  "route": {
    "rules": [{"inbound": "auto-in", "outbound": "auto"}]
  }
}

由 daemon 的 refresh_loop 落盘后调用 systemctl reload sing-box

5.3 daemon 启动入口

async def main():
    app = web.Application()
    app.router.add_get("/sub", handle_sub)
    app.router.add_get("/api/pool", handle_status)
    asyncio.create_task(refresh_loop())
    runner = web.AppRunner(app)
    await runner.setup()
    site = web.TCPSite(runner, "0.0.0.0", 9588)
    await site.start()
    log.info("daemon listening on :9588")
    await asyncio.Event().wait()

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())
```---

## 6. CN-Exit 出口黑名单(核心方法论)⭐

> **本节是 SOP 最大增量。** 几乎所有公开订阅项目都缺少这一层。

### 6.1 设计思路:旁路体检 + 覆写黑名单

本机(CN 出口) ├─ ssh 拉 URI 列表 ├─ 自启 mihomo(独立端口 19090) ├─ /proxies/{name}/delay (gstatic 204) ├─ 三轮 × 5s timeout ├─ 全 3 轮 delay==0 → dead ├─ name 反查 URI → parse_endpoint → host:port ├─ 写 cn_blacklist.json └─ scp → VPS:pool_data/cn_blacklist.json │ └─▶ /sub 热读,立即过滤生效(无需重启 daemon)


**优势**:
1. **不动主服务** — daemon 完全不知道 cn_blacklist 来自哪里,只在 `/sub` 出口处读一次
2. **无需重启** — daemon 每次 HTTP 请求都热读,scp 完即生效
3. **失败安全** — 本机网络抖动时立即 abort,绝不污染 VPS

### 6.2 mihomo file-provider 协议级 healthcheck

mihomo(原 Clash.Meta)的 `/proxies/{name}/delay` API 是**真正的协议级体检**:会建立完整代理链路并请求 `http://www.gstatic.com/generate_204`,`delay==0` 表示**任何一层失败**(DNS / TLS / 代理握手 / 出口访问)。

mihomo 配置(核心):

```yaml
external-controller: 127.0.0.1:19090
mode: rule
log-level: warning

proxy-providers:
  agg-pool:
    type: file
    path: ./proxies.yaml
    health-check:
      enable: false   # 手动触发,不用自动

proxy-groups:
  - name: GLOBAL
    type: select
    use:
      - agg-pool

rules:
  - MATCH,GLOBAL

端口选择:用 19090 避开本机已有代理客户端(通常占 9090)。

6.3 三轮独立体检代码

import asyncio, aiohttp
from urllib.parse import quote as urlquote

MIHOMO_PORT = 19090
CONCURRENCY = 30
TIMEOUT_MS = 5000
ROUNDS = 3

async def delay_check(session, name: str) -> int:
    """单次协议级体检,返回 delay(ms),0=失败"""
    url = f"http://127.0.0.1:{MIHOMO_PORT}/proxies/{urlquote(name)}/delay"
    params = {
        "timeout": str(TIMEOUT_MS),
        "url": "http://www.gstatic.com/generate_204",
    }
    try:
        async with session.get(
            url, params=params, timeout=TIMEOUT_MS / 1000 + 3
        ) as r:
            d = await r.json()
            return d.get("delay", 0)
    except Exception:
        return 0

async def three_round_check(names: list) -> dict:
    """三轮体检,返回 {name: [d1, d2, d3]}"""
    sem = asyncio.Semaphore(CONCURRENCY)
    results = {}
    async with aiohttp.ClientSession() as session:
        async def _check_one(name):
            delays = []
            for _ in range(ROUNDS):
                async with sem:
                    delays.append(await delay_check(session, name))
                await asyncio.sleep(0.3)
            results[name] = delays
        await asyncio.gather(*(_check_one(n) for n in names))
    return results

为什么三轮:单轮可能因临时拥塞假阴。三轮独立判定容忍偶发抖动;全 0 才判 dead。

6.4 完整体检流程

import subprocess, time, json, yaml, re, requests
from pathlib import Path
from urllib.parse import unquote

BUILTIN_PROXIES = {"GLOBAL", "REJECT", "DIRECT", "COMPATIBLE", "PASS"}
GROUP_TYPES = ("Selector", "URLTest", "Fallback", "LoadBalance", "Relay")

def run_cn_health_check(
    vps_host: str,
    vps_user: str,
    remote_cache: str,
    remote_blacklist: str,
    mihomo_bin: str,
    work_dir: Path = Path("./cn_check_workdir"),
    min_alive_threshold: int = 30,
):
    work_dir.mkdir(exist_ok=True)

    # 1) SSH 拉 URI 列表
    subprocess.run(
        ["scp", f"{vps_user}@{vps_host}:{remote_cache}",
         str(work_dir / "agg_pool.json")],
        check=True,
    )
    pool = json.loads((work_dir / "agg_pool.json").read_text())
    uris = pool["uris"]
    print(f"[1/6] pulled {len(uris)} URIs from VPS")

    # 2) 写 mihomo 输入
    proxies_file = work_dir / "proxies.yaml"
    proxies_file.write_text("\n".join(uris))

    config = {
        "external-controller": f"127.0.0.1:{MIHOMO_PORT}",
        "mode": "rule",
        "log-level": "warning",
        "proxy-providers": {
            "agg-pool": {
                "type": "file",
                "path": str(proxies_file.resolve()),
                "health-check": {"enable": False},
            }
        },
        "proxy-groups": [
            {"name": "GLOBAL", "type": "select", "use": ["agg-pool"]}
        ],
        "rules": ["MATCH,GLOBAL"],
    }
    config_path = work_dir / "config.yaml"
    config_path.write_text(yaml.safe_dump(config))

    # 3) 启动 mihomo
    proc = subprocess.Popen(
        [mihomo_bin, "-d", str(work_dir), "-f", str(config_path)],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    )
    for _ in range(30):
        try:
            requests.get(f"http://127.0.0.1:{MIHOMO_PORT}/version", timeout=1)
            break
        except Exception:
            time.sleep(0.5)
    else:
        proc.terminate()
        raise RuntimeError(f"mihomo failed to start on :{MIHOMO_PORT}")
    print("[2/6] mihomo started")

    try:
        # 4) 列出真实节点(排除内置 + group 类型)
        r = requests.get(f"http://127.0.0.1:{MIHOMO_PORT}/proxies").json()
        all_proxies = r.get("proxies", {})
        names = [
            n for n, info in all_proxies.items()
            if n not in BUILTIN_PROXIES
            and info.get("type") not in GROUP_TYPES
        ]
        print(f"[3/6] found {len(names)} proxies to check")

        # 5) 三轮体检
        results = asyncio.run(three_round_check(names))
        dead_names = [
            n for n, delays in results.items()
            if all(d == 0 for d in delays)
        ]
        alive_count = len(names) - len(dead_names)
        print(f"[4/6] dead: {len(dead_names)} / {len(names)}, alive: {alive_count}")

        # 6) 安全阈值:本机断网保护
        if alive_count < min_alive_threshold:
            raise RuntimeError(
                f"ABORT: only {alive_count} alive (< {min_alive_threshold}), "
                "likely local network issue. NOT pushing blacklist."
            )

        # 7) name → host:port(关键:mihomo 同名后缀处理)
        name_to_uri = {}
        for uri in uris:
            body, _, frag = uri.partition("#")
            node_name = unquote(frag) if frag else ""
            # 后定义的覆盖前定义的(mihomo 行为一致)
            name_to_uri[node_name] = uri

        blacklist = set()
        for dead_name in dead_names:
            uri = name_to_uri.get(dead_name)
            if not uri:
                # mihomo 对重名节点加 " - 01", " - 02" 等后缀
                base = re.sub(r"\s*-\s*\d+$", "", dead_name)
                uri = name_to_uri.get(base)
            if uri:
                ep = parse_endpoint(uri)
                if ep:
                    blacklist.add(f"{ep[0]}:{ep[1]}")
        print(f"[5/6] blacklist: {len(blacklist)} entries")

        # 8) 落盘 + scp 推送
        bl_data = {
            "ts": int(time.time()),
            "blacklist": sorted(blacklist),
            "stats": {
                "total": len(names),
                "dead": len(dead_names),
                "alive": alive_count,
            },
        }
        bl_path = work_dir / "cn_blacklist.json"
        bl_path.write_text(json.dumps(bl_data, indent=2))

        subprocess.run(
            ["scp", str(bl_path), f"{vps_user}@{vps_host}:{remote_blacklist}"],
            check=True,
        )
        print("[6/6] DONE. /sub will filter immediately (hot-read)")
    finally:
        proc.terminate()

6.5 关键坑全集(实战血泪)

# 表现 解法
1 mihomo 同名后缀 多节点同 fragment → mihomo 自动加 - 01/ - 02 反查时 regex strip 后缀
2 三轮全 0 才判 dead 单轮可能因临时拥塞假阴 3 轮独立判定,容忍偶发
3 alive < 30 abort 本机断网导致全部判 dead 然后覆写 → 全部节点被禁 硬阈值保护,绝不推送
4 全量覆写策略 只记"现在 dead 的",不增量累积 下次体检若节点复活自动解禁
5 fragment URL 编码 [AGG]%5BAGG%5D grep 时用解码后文本
6 端口冲突 本机可能已有 Clash 占 9090 用 19090 独立端口
7 mihomo 进程残留 异常退出后端口被占 try/finally proc.terminate()
8 provider 类型节点排除 把 GLOBAL/Selector 当节点测会拿到聚合 delay 过滤 type in GROUP_TYPES

7. 部署指南

7.1 VPS 端文件结构

~/proxy-pool/
├── daemon.py                    # 主服务(聚合+订阅,§3-§5 整合)
├── pool_data/
│   ├── agg_pool.json            # 缓存(daemon 写)
│   └── cn_blacklist.json        # 黑名单(本机 scp 推送)
├── venv/                        # Python 虚拟环境
└── logs/

7.2 systemd 服务(推荐)

# /etc/systemd/system/node-aggregator.service
[Unit]
Description=Node Aggregator Subscription Daemon
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/proxy-pool
ExecStart=/home/deploy/proxy-pool/venv/bin/python daemon.py
Restart=always
RestartSec=10
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now node-aggregator
sudo systemctl status node-aggregator   # 确认 active (running)

7.3 本机端 — 周任务配置

Linux (cron)

# crontab -e
30 9 * * 1 cd ~/cn-health-check && /usr/bin/python3 cn_health_check.py >> logs/check.log 2>&1

Windows (Task Scheduler)

触发器: 每周一 09:30
操作:   pythonw.exe cn_health_check.py
工作目录: C:\Tools\cn-health-check

7.4 环境变量

# VPS 端 daemon.py
export POOL_PORT=9588
export REFRESH_HOURS=6
export TCP_CONCURRENCY=200
export TCP_TIMEOUT=1.5

# 本机端 cn_health_check.py
export VPS_HOST=<your-vps-ip-or-host>
export VPS_USER=<your-deploy-user>
export REMOTE_CACHE_PATH=~/proxy-pool/pool_data/agg_pool.json
export REMOTE_BLACKLIST_PATH=~/proxy-pool/pool_data/cn_blacklist.json
export MIHOMO_BIN=/usr/local/bin/mihomo   # Win: C:\Tools\mihomo.exe

7.5 SSH 免密配置(必须)

# 本机
ssh-keygen -t ed25519 -f ~/.ssh/cn_check_key
ssh-copy-id -i ~/.ssh/cn_check_key.pub <user>@<vps>

# ~/.ssh/config 单独 alias 提高复用
Host pool-vps
    HostName <vps-ip>
    User deploy
    IdentityFile ~/.ssh/cn_check_key
    StrictHostKeyChecking accept-new

体检脚本中 vps_host 直接用 pool-vps 即可,不再硬编码 IP。


8. 抓取频率与维护

8.1 频率选择依据

组件 频率 原因
聚合抓取 6h 公开源通常每天更新 1 次,6h 平衡时效与带宽
TCP 探活 随聚合 (6h) 绑定刷新周期,无需独立调度
CN 体检 每周 1 次 成本约 2 min;节点出口变化在周级时间尺度
黑名单清理 全量覆写 非增量,节点复活自动解禁

8.2 告警阈值建议

  • /sub 返回节点数 < 60 持续 2 次刷新 → 增加订阅源
  • CN 体检 alive < 30 → 自动 abort,绝不推送(本机网络问题)
  • daemon cache 超过 24h 未更新 → 检查 VPS 网络 / systemd 状态

8.3 订阅源维护

公开 GitHub 仓库会删除 / 迁移:

  1. 每月检查 AGG_SOURCES 是否 404,替换失活源
  2. 观察 awesome-vpn / aggregator 等索引发现新源
  3. 节点总量 < 200 时增补,> 600 时精简(重复率过高得不偿失)

9. 验证 Checklist

部署完成后逐项验证:

  • [ ] curl http://<VPS>:9588/sub | base64 -d | head -5 → 看到 vless / vmess / ss URI
  • [ ] curl http://<VPS>:9588/api/poolalive_count > 100
  • [ ] v2rayN 添加订阅 http://<VPS>:9588/sub → 节点列表导入成功
  • [ ] 本机执行 cn_health_check.py → 输出 dead 数量 + 推送成功日志
  • [ ] 推送后再 curl /api/poolblacklist_size > 0alive_count 减少
  • [ ] 手动选几个 alive 节点连接,能正常访问境外站

10. 局限性与边界

局限 说明
公开节点质量有限 即使双重体检,速度仍远不如付费服务;适合临时 / 备用
短寿命不变 每次刷新 30-60% 节点更替,不适合长期固定连接
不替代住宅代理 持续性任务(注册、爬虫、登录态)建议用 WARP IP 轮转或购买住宅代理
体检覆盖面 只测 gstatic 204,不代表所有目标站可达;可扩展多目标轮询
协议范围 主要覆盖 vless / vmess / ss / trojan / hy2,对 wireguard / naive 等未覆盖
无并发会话隔离 多用户共用 /sub 会争抢同一批节点,企业场景需分用户分组

11. 扩展方向

  • 多地域体检:在 HK / JP / SG 分别部署体检客户端,输出 region-specific 黑名单
  • 延迟分级:基于 mihomo delay 数值打 tag,客户端按延迟过滤
  • 协议级 fallback:vless 失败时自动回退 ss,需在 sing-box 路由层配置
  • 流量统计:daemon 增加 /api/usage 端点,配合 sing-box stats API 输出每节点流量
  • 配额管理:基于客户端 token 限速 / 限连接数,防止公开节点被滥用

致谢

本 SOP 基于以下开源项目最佳实践提炼:

评论(0)

登录 后可发表评论。

暂无评论。