7.7 KiB
7.7 KiB
订阅聚合 · 小设计(待拍板)
目标:客户订阅在自有节点之外,可附加 ① 外部订阅源(面板定时拉取)② 手动节点链接,合并成一份订阅输出,"一个订阅用全部入站"。 现状锚点:订阅管线在
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(Nodeserver.js housekeeping()/ Gomain.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.jshousekeeping 挂到期扫描、客户 CRUD 校验新字段、加手动刷新端点。 - Go(生产主控,必须同步):新增
panel-go/extsub.go对应物;subscriptions.go的Entries/FormatSubscription同步拼接;main.gohousekeeping 挂扫描;handlers.go同步 API。字段同名同义、JSON 库双向兼容(两边都能读对方写的文件)。 - agent / agent-go:零改动——聚合纯主控侧,agent 只管自有节点的部署与计数上报,协议不变。
- 验收:
panel-go/difftest差分 E2E 增加聚合用例(同种子库 + 本地假外源 fixture,两实现五格式输出逐字节比对),过不了不算完。
7. 分期
- 首版(本设计全量):字段与缓存、链接列表解析、五格式合并输出、定时+手动刷新、失败兜底、SSRF 禁内网、双实现同步。
- 二期:Clash YAML / sing-box JSON 源解析;外部节点健康探测与失效自动剔除;按源建 Clash 分组;手动/外部与自有交错排序;按源流量估算展示(仅展示、不计费)。
需拍板(默认即推荐值):刷新 60 分钟 / 内网源默认禁 / 外部项排自有之后 / 停用整单停发 / 首版不吃 YAML。