Task 3 设计 · 让用户像 Shadowrocket 那样自定义路由规则(哪些直连 / 走隧道 / 拒绝)。
配套视觉原型:交互原型 → · 状态:Phase 1 已实现(feat/configurable-proxy;实现计划 阅读版)
当前 pangolin 客户端的分流是服务端固定渲染的:LAN 直连、DNS 劫持、可选国内分流(geoip/geosite-cn)、私有域名走隧道,用户无法自定义。实际需求(如 CI 编译源、公司内网、某些国内站要直连;某些站要强制走隧道;广告要拒绝)无处配置——这次暴露的「CI 流量被灌进隧道拖垮小节点」就是典型。
本设计给用户一套可配置路由规则,心智模型对齐 Shadowrocket 的 [Rule] 段:有序规则表,首命中生效,动作三选一(直连 / 走隧道 / 拒绝)。
ARCHITECTURE.md §3.1:Dart/Flutter 客户端不得自行拼装或修改 sing-box 配置——配置一律服务端渲染、原样下发。所以"客户端可配置"不能变成"客户端本地改 config"。解法:用户在 App 编规则 → 存为服务端 per-user「路由档案(routing profile)」→ connect 时服务端把档案翻译进渲染的配置。客户端只负责编辑 UI + 存/取档案,永不碰 sing-box JSON。
App 规则编辑器 ──PUT /v1/routing-profile──▶ 服务端存 per-user 档案(DB)
│
App 点连接 ──POST /v1/nodes/{id}/connect──▶ BuildClientConfig(读档案→翻译成 route.rules)
│
App ◀────────── 完整 sing-box 配置(含用户规则)─────┘ 原样喂内核
一条规则 = { type, value, action, note?, enabled }。整个档案:
{
"mode": "rule", // global | rule | direct(对齐 shadowrocket 三模式)
"builtin": {
"china_direct": true, // 国内分流(geoip-cn/geosite-cn → 直连)开关
"lan_direct": true, // LAN/私网直连(强制,不可关)
"private_via_tunnel": true // 私有服务域名走隧道(服务端下发,不可关)
},
"rules": [ // 用户自定义,有序,首命中生效
{ "type": "domain_suffix", "value": "git.51yanmei.com", "action": "direct", "note": "CI 源", "enabled": true },
{ "type": "geosite", "value": "category-ads", "action": "reject" },
{ "type": "ip_cidr", "value": "35.190.0.0/16", "action": "proxy" }
],
"final": "proxy" // 兜底:未命中任何规则的动作
}
| type | 值示例 | sing-box 字段 |
|---|---|---|
domain | example.com | domain(精确) |
domain_suffix | aliyun.com | domain_suffix(含子域) |
domain_keyword | domain_keyword | |
ip_cidr | 35.190.0.0/16 · ::/0 | ip_cidr(v4/v6) |
geoip | CN · US | rule_set(自托管 geoip-*.srs) |
geosite | cn · netflix · category-ads | rule_set(自托管 geosite-*.srs) |
| action | outbound | 说明 |
|---|---|---|
| direct | direct | 物理网卡直连(配合 route_exclude/reverse_mapping 真直连) |
| proxy | auto | 经节点(REALITY/Hy2 urltest 择优) |
| reject | block | 阻断 |
服务端把规则按固定层级拼进 route.rules,自上而下首命中:
PANGOLIN_PRIVATE_SPLIT_DOMAINS)· 服务端rules[] 顺序展开)← 新增china_direct 开时)final:proxy / direct)确定性,不靠猜——冲突由层级 + 顺序唯一裁决:
UI 主动提示两类冲突(编辑器里实时标记,不静默):
| 冲突 | 提示 |
|---|---|
| 被前面规则遮蔽、永不命中 | 规则灰掉 + 「已被上面「X」覆盖,永不命中」 |
| 撞到锁定的系统目标(如给私有服务域名加规则) | 「该域名由系统强制走隧道,此规则不生效」 |
| 模式 | 行为 | 用户规则 |
|---|---|---|
| 智能分流(默认) | 完整规则链:系统层 → 用户规则 → 国内分流 → FINAL | 生效 |
| 全局代理 | 除系统层(LAN/私有服务)外全走隧道 | 忽略 |
| 全部直连 | 除系统层(私有服务走隧道)外全直连 | 忽略 |
系统层(层1-3)在任何模式都强制生效——全局/直连只改 FINAL 兜底 + 是否套用用户规则,不会关掉 DNS 劫持/LAN 直连/私有服务。UI 在非智能分流模式下把「我的规则」区灰化并注明"当前模式忽略以下规则"。
现在 Settings 的 Smart routing 是二元开关(= builtin.china_direct)。改为渐进式披露入口:该行变成「分流规则」设置行(右侧显示当前模式 + chevron →),点进去即分流规则屏。休闲用户维持"智能分流"不点即可(等价现在开关开着),高级用户点进去自定义。老的二元开关被三模式(全局/智能分流/直连)涵盖。
direct outbound 让 sing-box 从物理网卡直接出连接。但 TUN strict_route 会把包重新捕回隧道——已有两个机制解决,用户规则复用:
route_exclude_address(在 auto_route 层排除,已用于 LAN)。用户加的 ip_cidr → direct 需同步进 route_exclude,否则被 strict_route 抓回。dns.reverse_mapping(记 IP↔域名,按 IP 连接时补回域名元数据让 domain 规则命中)+ local DNS(223.5.5.5,国内解析)。已用于私有域名分流,机制现成。| 端点 | 作用 |
|---|---|
GET /v1/routing-profile | 拉当前用户档案(App 编辑器初始化;无则返回内置默认) |
PUT /v1/routing-profile | 保存档案(服务端校验:CIDR 合法、type 合法、条数上限、去重) |
POST …/connect(现有) | 渲染时读该用户档案,翻译进 route.rules |
档案存 DB(routing_profiles 表:user_id · profile_json · updated_at),不走 connect body(与现有 split_cn 走 query 的约束一致——大规则集不塞 query/body)。客户端本地也缓存一份(离线可看/编,连接时以服务端为准)。
ip_cidr 可解析、type 在白名单、geosite/geoip 值在自托管规则集清单内、总条数 ≤ 上限(如 200)。非法整体拒绝(4xx + 逐条错误),不半保存。| 现有 | 本设计如何吸收 |
|---|---|
| SplitCN(geoip/geosite-cn 直连,#5) | 降为档案里的 builtin.china_direct 开关(层级 5) |
| PrivateSplitDomains(走隧道) | 保留为系统层 3(服务端 env,用户不可动) |
| route_exclude_address(LAN) | 保留 + 扩展:用户 ip_cidr→direct 规则动态并入 |
| reverse_mapping / local DNS | 复用:域名直连规则靠它命中 |
换句话说,本设计是把三个散落的分流机制统一收进一个用户可见、可配的规则模型,底层复用已验证的 sing-box 手法。
确认这份设计后,我用 writing-plans 出 Phase 1 的实现计划(TDD、多端、服务端翻译逐条测),再开发。