Files
NexusGate/SUB-AGGREGATION-DESIGN.md
T

7.7 KiB
Raw Blame History

订阅聚合 · 小设计(待拍板)

目标:客户订阅在自有节点之外,可附加 ① 外部订阅源(面板定时拉取)② 手动节点链接,合并成一份订阅输出,"一个订阅用全部入站"。 现状锚点:订阅管线在 lib/subscriptions.js(Go:panel-go/subscriptions.go),入口 formatSubscription / FormatSubscription,条目为 {nodeId, clientUri}、名字挂在 URI 的 # 后,输出 raw/base64/clash/clash-smart/singbox/surge 五格式;Clash 等格式都经 clashProxy 从 URI 反解,不支持的 scheme 会让整单生成抛错——聚合的所有过滤必须在进管线前完成。

1. 数据模型(存进现有 JSON 库,跟随现状形态)

  • 客户新增 externalSources: [{id, url, remark, enabled, userAgent?}],上限 5 个/客户——防滥用且够用;字段风格与现有客户字段一致(camelCase,如 trafficResetDay)。
  • 客户新增 manualLinks: [{id, uri, remark, enabled}],上限 50 条/客户;保存时即做 scheme 白名单与可解析校验,坏链接不许入库。
  • 拉取缓存放顶层对象 subSourceCache: { [sourceId]: {fetchedAt, okAt, failCount, lastError, droppedCount, items:[{uri, name}]} },仿 keyStore 先例(对象形态、不入 COLLECTIONS 校验;Node 侧 ||= {} 兜底、Go 侧 Data 加 map 字段)——缓存与客户配置分离,编辑客户不碰缓存,源被删时顺手清对应缓存键。

2. 拉取与缓存

  • 刷新间隔:全局设置 settings.externalSubRefreshMinutes,默认 60 分钟,可配 15–720;机场源普遍限频,60 分钟兼顾新鲜与不被风控。调度挂现有 60s housekeeping(Node server.js housekeeping() / Go main.go housekeeping()),按每源 fetchedAt + 间隔 判到期、加 ±10% 抖动防同刻齐发——与月重置同为"落盘标记判定、不靠内存计时器",面板重启不丢节奏。
  • 单次拉取上限:超时 10s、响应体 1 MiB、解析后条目 200 条/源、重定向 ≤3 跳;超限即判失败走兜底。
  • 失败策略:失败只累加 failCount 并退避(间隔 ×2ⁿ,上限 24h),永远沿用上一次成功缓存输出;failCount ≥ 3 时客户详情把该源标红提示。从未成功过的源输出为空、状态显示失败原因。自有节点的生成路径与外源完全解耦——外源拉取/解析出任何错,自有部分照常出(现状"无自有节点出 503"的判定改为"自有+手动+外源全空才 503")。
  • 触发入口:新增/编辑源保存后立即异步拉一次;客户详情每源一个「立即刷新」按钮(POST /api/customers/:id/external-sources/:sid/refresh,同步返回条数/错误供回显);到期由 housekeeping 自动拉。housekeeping 内串行执行、每轮最多 3 个源,护路由器 CPU。
  • 缓存过期:成功缓存不设硬过期——兜底优先于新鲜,源死了老节点继续发,总比订阅空掉强;新鲜度只由 okAt 在 UI 上展示。

3. 解析范围(首版只吃"链接列表")

  • 输入形态按序判定:① 整体 base64 解码成功且解出含 scheme 的行 → 按行拆分;② 明文逐行(跳空行);③ 整个 body 就是一条链接(①②的自然子集)。覆盖 v2rayN 系与绝大多数机场的默认返回。
  • 协议白名单 = 现管线五种:vless://、ss://、hysteria2://、anytls://、socks5://(正是 buildClientUri 产物、clashProxy 可反解的集合)。vmess://、trojan:// 及未知 scheme 丢弃并计数(droppedCount 落缓存、UI 可见"已忽略 N 条")——一条坏链接绝不能毁整单。
  • Clash YAML / sing-box JSON 源首版不解析,放二期:两者是完整配置语义,做 outbound→URI 逆向映射等于再造半个转换器,且与五格式输出管线重复;用下方 UA 策略拉到的基本都是链接列表,首版覆盖面已够。

4. 合并与输出

  • 排序固定分段、不交错:自有节点(按定版 sortIndex 序)→ 手动链接(列表序)→ 外部源(源添加序、源内保持原序)。自有是计费与可用性主体、排前面保证客户端默认落自有;分段固定使外源抖动不改变自有相对顺序,订阅内容 diff 稳定。
  • 命名:外部项名 = [源备注] 原名(源备注为空则不加前缀);手动项名取备注 → URI 自带 #名 → 手动节点 N 兜底。合并后全局同名沿用现有后缀逻辑(名 (2)、Clash 侧 (节点N)),与自有重名处理完全一致。
  • 去重键:scheme + 主机(小写)+ 端口 + sha256(URI userinfo 原文) 前 16 位;全局去重、优先级 自有 > 手动 > 外部——外部若与自有指向同一节点,保自有(凭据与流量统计都在自有侧)。
  • 流量口径不变:subscription-userinfo 与额度判定只读自有三计数(usedBytes 等),外部/手动流量不计量、不参与暂停判定;按月重置只清自有计数,互不干扰。
  • 停用/暂停:跟随现有订阅端点整单门禁(handleSubscription 已对非 active/过期/超额整单 403),外部项不单开任何发放通道。与"迁移无感"原则的关系:无感指凭据与端点不变,停用语义对客户只有一种——不出现"自有停了、外部还能用"的割裂状态;且刷新对暂停客户照常跑,恢复启用瞬间外源就是新鲜的。

5. 安全与边界

  • SSRF 默认禁内网:拉取前解析主机名到 IP,命中环回/私网/链路本地段(含 169.254、::1、fc00::/8、fe80::/10)一律拒绝;重定向每一跳复检;连接时以校验过的 IP 直连并保留 Host/SNI(防 DNS rebinding)。面板在路由器上能摸到整个内网,这条是硬底线。设置 settings.allowPrivateSubSources 默认 关,用户有自托管订阅源时才手动打开。
  • 只接受 http/https、URL 长度 ≤ 2048;源 URL 在 UI、日志、activity 审计里一律脱敏(只显 scheme+host,query 打码,沿用现有 redact 思路);订阅 token 与源 URL 都不落明文日志。
  • 拉取 UA:默认 v2rayN/6.55——机场对该 UA 普遍回 base64 链接列表,正中首版解析面;允许按源填 userAgent 覆盖。
  • 面板出站拉取走主控自身网络,不经任何节点转发;拉取结果只进缓存、不执行其中任何内容。

6. 双实现影响

  • Node:新增 lib/extsub.js(拉取+解析+归一化);lib/subscriptions.js 在 entries() 后拼接聚合段;server.js housekeeping 挂到期扫描、客户 CRUD 校验新字段、加手动刷新端点。
  • Go(生产主控,必须同步):新增 panel-go/extsub.go 对应物;subscriptions.go 的 Entries/FormatSubscription 同步拼接;main.go housekeeping 挂扫描;handlers.go 同步 API。字段同名同义、JSON 库双向兼容(两边都能读对方写的文件)。
  • agent / agent-go:零改动——聚合纯主控侧,agent 只管自有节点的部署与计数上报,协议不变。
  • 验收:panel-go/difftest 差分 E2E 增加聚合用例(同种子库 + 本地假外源 fixture,两实现五格式输出逐字节比对),过不了不算完。

7. 分期

  • 首版(本设计全量):字段与缓存、链接列表解析、五格式合并输出、定时+手动刷新、失败兜底、SSRF 禁内网、双实现同步。
  • 二期:Clash YAML / sing-box JSON 源解析;外部节点健康探测与失效自动剔除;按源建 Clash 分组;手动/外部与自有交错排序;按源流量估算展示(仅展示、不计费)。

需拍板(默认即推荐值):刷新 60 分钟 / 内网源默认禁 / 外部项排自有之后 / 停用整单停发 / 首版不吃 YAML。