Files
NexusGate/SUB-AGGREGATION-DESIGN.md

56 lines
7.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 订阅聚合 · 小设计(待拍板)
> 目标:客户订阅在自有节点之外,可附加 **① 外部订阅源(面板定时拉取)② 手动节点链接**,合并成一份订阅输出,"一个订阅用全部入站"。
> 现状锚点:订阅管线在 `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。