[RFC] network-policy:按当前网络环境自动切换 select 组代理 #2722
wangwei354
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
基于网络环境自动切换代理的分层架构提案(Cross-Project Design Proposal)
概要
让用户在 mihomo 配置里声明"什么样的网络环境算 office / home / mobile",并规定在每种网络下
select代理组应该选哪个代理。当宿主(clash-verge-rev 等 GUI)检测到网络变化时,内核自动按规则切换组的当前选择——等价于用户手动点了一下,不同之处在于用户上次的手动选择在同一网络下会被尊重、不会被自动流程覆盖。典型体验:回到家自动走
DIRECT;到公司自动走 HK 节点;插手机热点自动走省流量线路。分工:
select组的自动/手动状态NetworkContext,通过 REST 推给内核networks:+network-policy:)+ REST endpoint(PUT/DELETE/GET/network/context)最小示例:
0. 为什么把这份文档同时写给两个项目
该功能的需求早就存在,但两边维护者都认为该功能依赖另一方,导致无法推进。
梳理之后发现,功能的实现必须跨项目,在内核和 GUI 层面都需要新增功能。这份文档希望先把以下问题谈清楚:
1. 需求定义
1.1 用户故事
桌面端用户使用带 GUI 的 clash 生态(如 CVR)时,希望:
Proxy组自动选HK-AUS-BDIRECT(省流量)此外在多网卡并存场景下还希望:
iface-type: vpn能命中 wg01.2 先例参考:Stash iOS 的
ssid-policyStash 在
select组上提供 SSID → 代理的映射,网络变化时自动改组的当前选择。这是同类需求在手机端的一种实现。本方案不追求 YAML 兼容——手机侧假设单一网络,桌面需要"集合语义"。1.3 桌面场景的复杂度
office-5g / office-2.4g / office-guest常见并存结论:桌面需要把"同时活跃的所有网卡"都打包成一个集合 push 给内核,规则则按列表顺序 first-match——"我处于哪个网络" 变成"哪条规则首先命中这一接口集合"。
2. 两个项目的现状与边界
2.1 mihomo(内核)
auto-route、auto-detect-interfacedhcp://上游、系统 DNS / DHCP DNS 获取select的当前选择、store-selected持久化)、规则引擎的求值,天然是内核能力2.2 clash-verge-rev(宿主)
src-tauri/src/)已承担大量**非"纯前端"**职责:core/sysopt.rs:系统代理core/service.rs:系统服务 / IPCcmd/network.rs:基本网络接口列出core/handle.rs:mihomo REST 客户端feat/profile.rs、feat/config.rs:配置 merge / scriptwindowscrate 直接调 WlanAPI / IP Helper /GetAdaptersAddressessystem-configuration/objc2-core-wlan(无 CGO)netlink-packet-route/nl802112.3 两个项目的"传统分工"
本提案沿着这条已有边界做划分。
3. 核心观察:这个功能"天然"由两部分组成
3.1 两个子任务
网络环境检测
策略求值与状态管理
select组的Set()store-selected3.2 两部分的"天然归属"
select组的store-selected、代理组运行时状态是同一类概念;多 GUI 复用、配置可移植性都要求它在内核3.3 规则存在哪——决定性因素
规则存在内核(mihomo YAML)还是宿主(CVR 本地状态),决定了:
在 clash 生态里"YAML 即契约",规则离开 YAML 会让功能永远是 CVR 专属。这条约束在下面的方案对比里权重很高。
4. 三种架构方案对比
4.1 方案 A:全部放宿主(CVR)
做法:CVR 监听网络变化 → 在 CVR 内部存规则 → CVR 求值 → 调 mihomo REST API 切
select优点:内核零改动、迭代快、规则 UI 可做得很丰富
缺点:规则存 CVR 本地 → 不随 YAML 走 / 不随订阅走;其他 GUI 要各自重实现;自动/手动状态机要在 CVR 重新造;用户看 YAML 看不到"为何代理自动跳变"
4.2 方案 B:宿主检测 + 内核决策(推荐)
做法:
NetworkContext(含 interfaces[]),通过 REST 推给内核networks:+network-policy:)、运行评估器、切换select、管理自动/手动状态优点:
select+store-selected的已有语义network-policy.default在启动路径上承担兜底(仅当落入 §5.6.2 分支 B;落入分支 A 时恢复 cachefile 状态,不走 default。详见 §5.6.2 / §7.3)缺点:需要两个项目协作;新增一个 REST endpoint 对外(安全面稍增,复用 external-controller 的 auth 即可)
4.3 方案 C:全部放内核(mihomo)
做法:mihomo 自己监听系统网络事件 + 规则 + 求值 + 切换
优点:任意前端、甚至 CLI 都自动享有;headless 部署原生支持
缺点:把跨平台宿主感知逻辑塞进内核;Go + CGO 在 macOS 上对交叉编译 / 签名 / 发布流程有显著影响;与现有 mihomo 边界不符;维护者永久背三套平台后端
4.4 方案对比总表
store-selectedstore-selected推荐方案 B。
5. 推荐方案:B 的一种具体切法
5.1 总体原则
store-selected保持同质)5.2 内核(mihomo)做什么
networks:— 定义网络身份(SSID / BSSID / gateway-mac / iface-type / subnets 等匹配器)network-policy:作为select组的子字段,内容为 network name → 代理名 的映射NetworkContext(包含interfaces[])network-policy的select组各自的命中 network name + 应用的 proxystore-selected、"手动优先"状态机交互(§5.6)/network/context资源下)PUT /network/context— 宿主推送/续约状态DELETE /network/context— 显式清除(clean shutdown 用)GET /network/context— 查看当前 context + 剩余 ttl + 各组匹配/应用结果GET /configs响应的tun.device字段必须返回 listener 实际 bound 的 iface 名(不是配置原值)。host sampler 需要这个 ground truth 来过滤"mihomo 自己的 TUN"。详见 §5.4.7 (a)sing_tunlistener 的 auto-detect 路径当前已经把运行时解析出的tunName写回options.Device(见listener/sing_tun/server.go中sing_tun.New()对options.Device == ""/!checkTunName的处理),因此这一条主要是把现有隐式行为上升为显式契约 + 补回归测试FileDescriptor > 0的 fd-override 路径当前getTunnelName(fd)的结果只写入局部变量tunName,未写回options.Device,导致GET /configs.tun.device仍返回用户配置原值(可能为空)。实施时需同时修复这条路径ttl,单位秒);不传即 stickyDELETE /network/context:仅清 kernel 缓存的 ctx 快照,select组状态机(source/last_matched_network/ 已 selected proxy)原样保留;既不触发评估,也不走network-policy.default。下次 PUT 到来时按保留状态 + 新 ctx 走 §5.6.2 各分支(详见 §5.4.3 与 §5.6.4)5.2.1 安全面说明
PUT /network/context能够影响代理选择,敏感度接近PUT /proxies/:name。secret:与现有/proxies风险同档,复用认证即可secret:启动时输出 warning(不自动拒绝启用 network-policy,由用户决定)external-controller-tls端点若配tls.client-auth-cert且认证模式为强制验证(require-and-verify)→ 告警豁免;弱认证模式(request/require-any/verify-if-given)不豁免5.3 宿主(CVR)做什么
src-tauri/src/module/netmon/(三平台 sampler)NWPathMonitor/SCDynamicStore+NSWorkspaceDidWakeNotificationNotifyIpInterfaceChange+INetworkListManager::NetworkConnectivityChanged+WM_POWERBROADCAST / PBT_APMRESUMEAUTOMATICRTMGRP_LINK / RTMGRP_IPV4_IFADDR / RTMGRP_IPV4_ROUTE(须监听ENOBUFS并主动 dump 重同步)^(docker|br-|veth|vmnet|vEthernet|virbr);overlay VPN 如tailscale/zerotier/warp不过滤)enable_virtual_iface_reporting: bool(默认 false)可打开虚拟桥上报,给需要match: { name: docker0 }的用户gateway_ip填充规则(每张 iface 独立判定)gateway_mac仅在gateway_ip有值时通过 ARP/NDP 查dns_suffix跨平台采集resolvectl domain)优先 →/etc/resolv.conf的search→ NetworkManager DBus;systemd-resolved routing-only domain(~前缀)要排除SCDynamicStore::State:/Network/Global/DNS → SearchDomains(系统已聚合)GetAdaptersAddresses的SuffixSearchList(优先)→DnsSuffix(fallback)handle::Handle::mihomo()调PUT /network/contextGET /network/context返回的 interfaces[] 清单5.4 Context 数据契约
5.4.1
PUT /network/context请求体{ "version": 1, // 必填;固定为 1 "interfaces": [ // 必填;可为空数组 { "name": "wlan0", // 必填;iface 名,集合内唯一;≤ 255 字节 "iface_type": "wifi", // wifi | ethernet | cellular | wwan | vpn | loopback | other "ssid": "office-5g", "bssid": "aa:bb:cc:dd:ee:00", "gateway_ip": "10.0.0.1", // 填充 iff sampler 判定该 iface 为"用户视角的默认路由候选" "gateway_mac": "11:22:33:44:55:66", // 仅在 gateway_ip 填充时才填 "subnets": ["10.0.0.0/24"], // iface 本地地址前缀;normalize 去空 + 排序 + 去重 "metered": false // tri-state null / true / false }, { "name": "wg0", "iface_type": "vpn" } ], "dns_suffix": ["corp.example.com"], // 必须是数组(scalar 被拒);normalize 小写 + 去空 + 排序 + 去重 "ttl": 1800 // 可选;秒;nil = sticky / ≤0 → 400 }关键点:
interfaces与version是 wire-required 字段;missing / null 都触发malformed_bodyinterfaces硬上限 32;超过 → 400too_many_interfacesprimary_iface/is_primary/tun枚举——单接口选择权完全交给 rule 顺序subnets仅含接口本地地址前缀(getifaddrs 级),不含路由表里经过该 iface 的 next-hop CIDR(如 WireGuardAllowedIPs不会出现在 subnets 里)dns_suffix是顶层全局字段(不归属任何 iface),由系统 DNS 配置聚合得到;matcher 中的dns-suffix始终对应这个全局值5.4.2 响应
applied[]排序:按 proxy-group 在 YAML 中的声明顺序排列。这让客户端日志结构化对比稳定;GET /network/context的groups[]同样规则。reason 枚举(按每组运行时结果定义):
last_matched_networkmatchedMatch(ctx)命中某 network N,且本组network-policy.Mapping[N]有 target;成功 Setalready_selectedmatched触发条件,但 target 等于当前选择,未实际调 SetdefaultMatch(ctx)未命中,或命中 N 但本组Mapping对 N 无 target;本组 policy 有defaultkey 且 default 所指 proxy 在本组候选中 → 切到(或保持在)defaultproxy。完整触发条件见 §5.6.4<none>)no_change_no_defaultdefault的左半触发条件(未命中 / 命中但 Mapping 无 target),但本组 policy 无defaultkey → 保持当前选择;source 仍重置为auto。完整触发条件同default,见 §5.6.4default列)unchanged_networkmatched_result(含<none>)与本组last_matched_network相同,且本组source == auto→ 跳过评估(source == unknown走强制完整评估分支,不落此处)manual_lockedmatched_result(含<none>)与本组last_matched_network相同,且本组source == manual→ 尊重用户手动选择,不评估missing_targettarget_proxy(来自Mapping[N]或defaultkey),但该 target 不在本组当前候选中(provider 缺失、订阅变更等)→ 跳过切换;source / last_matched 不变(下次 PUT 仍会重试)关键区分:
matched与already_selected:都命中同一分支,差别只是"是否实际调用了 selector.Set()"default与no_change_no_default:都对应"命中但无 mapping" 或 "未命中",差别只是"policy 是否有 default key"unchanged_network与manual_locked:都对应"matched 与上次相同",差别只是 source 是否为manual5.4.3
DELETE /network/context清除 kernel 侧缓存的 ctx 快照(让"当前 ctx"视作 nil)。响应
204 No Content。关键:DELETE ≠ "重置为从未推送过的冷启动状态"。DELETE 只动 ctx 快照,
select组状态机(source/last_matched_network/ 已 selected proxy)全部保留。具体:source/last_matched_network/ 已 selected 的 proxy 全部保持原样network-policy.default这样 host clean shutdown 时发 DELETE 不会把用户的 manual 选择抹掉——下次启动 + 首次 PUT 到来时,状态机按保留的
source=manual+last_matched_network继续执行 "手动优先" 语义(§5.6)。DELETE 的语义是"清除 ctx 快照",不是"重置状态机"。与"冷启动落入 §5.6.2 分支 B"的区别:那种情况下初始
source=unknown、last_matched_network=nil("从未评估过",见 §5.6.1 取值定义),过 provider barrier 后按 §5.6.2 分支 B 规则做一次内部评估,评估后last_matched_network按各 reason 分支各自规则前进(default/no_change_no_default前进为命中的 network name 或<none>;missing_target保持nil);DELETE 之后source/last_matched_network仍是上次 PUT 留下的值(可能是具体 name /<none>/nil,取决于 DELETE 前的历史),不主动评估,也不会被 DELETE 清回nil。DELETE + 热重载组合(corner case):用户发 DELETE 之后,在 host 离线期间直接 SIGHUP /
PUT /configs触发 kernel 热重载——此时 kernel 内 ctx=nil,按 §5.6.2 "配置热重载,此时 kernel 内无 ctx" 行不评估、不走 default、不更新状态机;用户上次的source=manual等待下次 PUT 到来时继续生效。这是"DELETE 语义保留状态机"在热重载路径上的一致性延伸,不是特例。DELETE 与
startup_eval_pending的关系(另一个 corner case):DELETE 清 ctx 快照,不清startup_eval_pending——分支 B 屏障期间若发生 DELETE(例如屏障期 PUT 走了 missing_target、紧接着 host clean shutdown 发 DELETE),屏障解除时仍按 pending 触发补评估(由于 ctx=nil,走 §5.6.4 触发源 2 "按 matched=<none>评估")。理由:分支 B 的"启动时用 provider-ready 候选集兜底评估一次" 责任独立于 DELETE 语义;startup_eval_pending是 transient 启动态,不是持久化状态机的一部分。TTL 过期:语义与 DELETE 等价——ctx 变 nil,状态机保持原样,不触发评估、不走 default;下次 PUT 到来时按保留状态 + 新 ctx 走 §5.6.2 各分支。
5.4.4
GET /network/contextCVR 可用此 endpoint 做周期性诊断刷新。
matched_network与groups[].last_matched_network在 wire 上的 null 语义见 §5.6.4 "内部哨兵与 wire 编码对照"。groups[]的 scope:与 §5.4.2applied[]一致——仅枚举带network-policy字段的select组,按 YAML 声明顺序排列。普通select组(无network-policy)、url-test/fallback/load-balance等组不出现在groups[]中。无 context 时的返回(冷启动未 PUT / DELETE 后 / TTL 过期后):HTTP 200 +
context: null+matched_network: null+expires_at: null+age_seconds: null。groups[]仍按"每组一项"返回(含current_proxy+selection_source+last_matched_network),让 host 轮询逻辑保持一致(无需做 200 vs 404 两路分支)。5.4.5 字段语义与归一化
通用规则:
version:必须为 1;其他值 → 400invalid_versionjson.Unmarshal默认行为),便于 future additive 扩展ttl:省略 = sticky;> 0→ 该秒数后过期;≤ 0→ 400invalid_ttl;上限MaxTTLSeconds = 10 年设计原则:wire 字段对未知 key 宽容忽略(forward-compat,未来 schema 演进 / 老 kernel 新 host 互通);YAML matcher 对未知 key 严格 fail-fast(用户配置防呆)。这是有意的非对称策略,详见 §7.2。
顶层字段归一化:
dns_suffix:接受[]string或null/ 省略(两者等价,视作空数组);其他任何类型(scalar string / number / boolean / object / array-of-non-string)→malformed_body。每项全小写、去空串、字典序排序、去重。每项禁止包含逗号、空白、控制字符(防 fingerprint 内部 join aliasing)InterfaceContext 归一化:
iface_type:小写;非枚举值 → 400bssid/gateway_mac:归一化为小写冒号分隔;非法 MAC → 400gateway_ip:netip.ParseAddr;IPv6 strip zone(避免netip.Prefix.Contains失效)gateway_mac无值但gateway_ip有值 → 允许;反之(gateway_mac有值但gateway_ip空)→ 400invalid_gateway_combometered:tri-state*bool;区分 null / true / falsename:必填非空;集合内唯一(重复 → 400duplicate_iface_name);长度 ≤ 255 字节ssid:长度 ≤ 32 字节(IEEE 802.11 规范上限);超长 → 400invalid_field。保留原始大小写与字节序列(IEEE 802.11 SSID 是 octet string,大小写敏感、不做 trim / unicode 归一化),matcherssid: office-5g与 ctxssid: Office-5G不命中;CVR sampler 侧应对异常超长值做 defensive drop 避免触发 400bssid:语义仅对iface_type=wifi有意义;其他 type 出现 bssid 不视为错误(host sampler 应做 defensive drop,但 wire 层不强制)——matcher 编译时bssid字段在 non-wifi iface 上自然不会命中subnets:每项按 CIDR 规范化(mask 到 prefix 长度,字符串序排序去重)。带 IPv6 zone 的 CIDR 条目(如fe80::1%eth0/128)由 sampler 在上报前剥掉 zone;kernel 不主动 strip subnets 的 zone——列表字段每项都 strip 的成本外包给 sampler 更合适。与gateway_ip(单值字段,kernel 主动 strip zone)的不对称是刻意的无 timestamp 字段:过期时间由"内核接收时刻 + ttl"决定,不依赖客户端时钟。
5.4.6 Matcher 语法
networks:每条的match:定义对 context 的匹配条件。命名约定:YAML 使用 kebab-case,JSON context 使用 snake_case。
matcher key ↔ context field 映射表:
nameinterfaces[].nameiface-typeinterfaces[].iface_typecellular;USB / PCIe 蜂窝模块(4G / 5G dongle)→wwan。若想覆盖所有蜂窝场景建议写iface-type: [cellular, wwan]ssidinterfaces[].ssidbssidinterfaces[].bssidgateway-macinterfaces[].gateway_macgateway-ipinterfaces[].gateway_ipsubnetsinterfaces[].subnetsdns-suffixdns_suffixmatcher 合法 key 必须是上表中的一项;未知 key、
metered、primary-iface等均 fail-fast。metered在 schema 层仍保留(wire 字段未来可能扩),但 matcher 层禁用——原因见本节末尾。编译模型(AST 三元组):每个 match-block 被编译为
(ifacePred, globalPred, combinators):ifacePred= 顶层所有 per-iface-field 合并成一个原子谓词(AND 在同一 iface 上)globalPred= 顶层 global-field(目前只有dns-suffix)的谓词combinators=any: / all: / not:子块,递归求值评估公式:
P_empty= "顶层没有 per-iface field"——此时不套 ∃ 包装(让只含 global / combinator 的 block 在interfaces=[]下也能评估)∃iface P(iface)默认是存在量词——"至少有一张 iface 同时满足这个原子谓词"all:拆开空集合行为:
interfaces=[]下,任何 P 非空的 block → 结果 falsenot:对空集下的子 block 反转 → true(符合"不存在满足条件的 iface" 直觉)——此时仅依赖 global 字段(dns-suffix)或not:的 rule 可能命中host "完全离线"时发什么:host 已确认"当前活跃 iface 集合为空"且仍在运行时,上报
PUT { interfaces: [] },让内核按 matched=<none>正常走 §5.6.2 的default/no_change_no_default/missing_target分支。发 DELETE 仅限以下三种场景:关键区别:
PUT { interfaces: [] }触发评估并按default兜底;DELETE不触发评估、不走 default、保留source/last_matched(详见 §5.4.3、§5.6.4)。两者在用户可见行为上完全不同,host sampler 不得混用。null-field 语义:
not: { ssid: X }在 "所有 iface 都缺 ssid"(例如只有有线)时为 true ——这是"不存在任何 iface 的 ssid = X" 的自然读法最小语法集示例:
逻辑语义:
any:子表达式列表 → ORall:子表达式列表 → AND(允许不同子 block 落在不同 iface)not:→ NEGATION(接单个 block,不接 list)networks:列表顺序 first-match不做的事:
primary:/all-ifaces:作用域:primary 概念被 rule 顺序取代;全集语义not: any: [...]可以表达meteredmatcher(当前禁用):三平台 sampler 当前都未采集 metered(硬编码 null),若开放 matcher,not: { metered: true }因∃iface metered=true为 false 反转得 true,会 silent-always-match。wire 字段保留为 forward-compat,matcher 待 sampler 补齐后再开放5.4.7 Sampler 政策(宿主端纪律)
(a) mihomo 自身 TUN 的过滤
sampler 按 name 精确匹配过滤 mihomo 自己的 TUN(不进
interfaces[])。外部输入来源:sampler 通过 Clash REST
GET /configs读tun.device(即 listener 实际 bound 的 iface 名)。缓存状态机(host 实现侧;触发器语义中性描述,具体钩子由 host 代码决定):
过滤语义:
Known(n)→ 枚举时跳过iface.name == nUnavailable/Uninitialized→ 不过滤(mihomo TUN 以iface_type: vpn出现)utun*/tun*)——无法区分 mihomo 自己的 utun 和用户装的 WireGuardUnavailable / Uninitialized 下用户侧的兜底建议:
tun.device时是运行时分配(macOS 下随机utun3/utun5/ ...;Windows 下随机 alias),用户无法预知稳定名字。not: { name: "utun3" }这类写法在重启后很容易失效iface-type/dns-suffix/gateway-mac做 rule,避免 rule 本身对iface_type: vpn或name做存在性断言(若一定要断言 vpn 存在,接受 mihomo TUN 会偶尔命中的事实,等 Known(n) 状态就绪后自动恢复)tun.device: <固定名>可以把降级窗口内可预测的 name 写入 YAMLnot:排除,但代价是tun.device必须在 mihomo 配置里维护host 如何感知"mihomo core 就绪 / 重启 / 切换成功":是 host 实现细节,不入 wire 契约;推荐做法:
GET /configs直到 200,或检测 external-controller 端口可连接),ready 信号即为 trigger(b) 虚拟桥默认过滤
^(docker|br-|veth|vmnet|vEthernet|virbr)默认不进interfaces[]。overlay VPN(tailscale/zerotier/warp)不过滤。可通过enable_virtual_iface_reporting: bool配置打开。(c) Linux 路由表覆盖
sampler 只遍历
RT_TABLE_MAIN,不扫其他 policy routing 表。影响:wg-quick风格的 WireGuard 默认路由在 table 51820,wg0 的gateway_ip会持续为空——matcher{iface-type: vpn, gateway-ip: ...}在此场景 silent fail;文档需告知用户改用{iface-type: vpn, dns-suffix: corp.example.com}(填写 sampler 实际上报的完整 dns_suffix 项;dns-suffix是精确 token 交集匹配,不支持子串 / 后缀 / 通配)等替代。覆盖多表是 future work。(d) 接口集合截断政策(
interfaces > 32时)虽然内核硬限 32,host 端先做确定性截断:
physical (wifi/ethernet/cellular/wwan)>vpn>other>loopbackgateway_ip != null)的 iface 优先gateway_ip降级(wg-quick 场景下 gateway_ip 持续为空,按该规则会错误降级 wg0)name字典序pusher 截断时记录 warn 日志供诊断。截断是防御性降级,32 上限在桌面场景下罕有触发。
5.4.8 REST 错误响应契约
所有 REST endpoint 的错误响应:
标准 code 集合:
malformed_bodyversion/interfaces)/ 字段形态错误(dns_suffix非数组等)invalid_versionversion != 1invalid_ttlttl <= 0或> MaxTTLSecondstoo_many_interfacesinterfaces.len() > MaxInterfacesduplicate_iface_nameinterfaces[]中name重复invalid_fieldmessage格式约定field: <path>, reason: <why>,便于 host 日志结构化解析invalid_gateway_combogateway_mac填充但gateway_ip为空internal_errorhost 处理建议:
internal_error/ 未知 code → log 原样 payload5.5 为什么不是别的 B 的切法
也可以把 schema 放在宿主(CVR 持有规则,内核只做"切 select")——但这样:
PUT /proxies/:name转发器,其实是方案 A所以 B 的这个切法的本质是:规则必须在 mihomo YAML。
另一种可能:"把'哪张 iface 是 primary' 这个维度留在 schema 里"。本设计放弃这条路径,因为:
PrimaryInterface字段(但 VPN 上线后会被污染),Windows 有GetBestRoute2可推导(但无直接 API 暴露名称),Linux 内核层面根本没有"primary iface"这个概念(是我们为"默认路由出口"起的名字)——协议暴露一个各平台语义不齐的字段会把跨平台一致性成本永久外包给 host sampler本设计选择"多接口集合"而不引入 primary,host 端只负责描述(枚举接口,按客观事实填 gateway_ip),kernel 和 YAML 规则按偏好(rule 顺序)裁决。
5.6 手动优先(manual-wins)状态机
硬编码在内核里,不通过 YAML 配置。理由:
5.6.1 状态变量
内核为每个有
network-policy的select组分别维护:selection_source:auto/manual/unknownlast_matched_network:上次成功应用时命中的 network name。三种取值:"office")—— 上次评估命中了某 network<none>—— 上次评估时有 ctx 但不匹配任一 network(含PUT { interfaces: [] })nil/ 未初始化 —— 从未评估过(启动冷态,一次也没进过 §5.6.2 的评估分支);与source=unknown典型同时出现nil与任何matched_result(含具体 name 与<none>)比较一律视为不同。因此last_matched=nil的组在首次 PUT 或手动 PUT 后的 PUT 中必定走 "matched 变了"相关分支(matched/already_selected/default/no_change_no_default/missing_target),不会落入unchanged_network/manual_locked持久化:
selection_source与last_matched_network随store-selected一同持久化。仅在profile.store-selected: true时写盘。实现层落地:cachefile 用两个独立 bucket:
bucketSelected(已有):记录每组当前 selected 的代理名bucketNetworkPolicy(新增):记录{schema_version, source, last_matched_network}。其中schema_version是 bucket 自身字段(当前1),与 PUT body 的version字段无关——字段名刻意不用version以避免视觉耦合。加载时若schema_version != 1(未来演进保留),按"无 bucket"处理(落入分支 B),不阻塞启动、不报错内存中的状态与热重载:无论
store-selected是否开启,进程内内存中的状态在配置热重载时始终保留。5.6.2 事件处理
matched_network= 本次 context 在networks:列表中首个命中的 name;无命中时为<none>。PUT /proxies/:name(用户手动切)selection_source = manual;last_matched_network不变PUT /network/context,该组 source=unknownmatched/already_selected/default/no_change_no_default/missing_target之一(不会是unchanged_network/manual_locked)PUT /network/context,matched 未变且 source=autounchanged_networkPUT /network/context,matched 未变且 source=manualmanual_lockedPUT /network/context,matched 变了,Mapping 有 target 且当前选择 ≠ targetselector.Set(target);source=auto;last_matched前进matchedPUT /network/context,matched 变了,Mapping 有 target 且当前选择 == targetsource=auto;last_matched前进already_selectedPUT /network/context,matched 变了 / 未命中,Mapping 无 target,policy 有default且 default proxy 在本组候选中source=auto;last_matched前进(命中为 N,未命中为<none>)defaultPUT /network/context,matched 变了 / 未命中,Mapping 无 target,policy 有default但 default proxy 不在候选missing_targetPUT /network/context,matched 变了 / 未命中,Mapping 无 target,policy 无defaultsource=auto;last_matched前进no_change_no_defaultPUT /network/context,policy 从 Mapping 求得 target 不在本组候选missing_targetsource/last_matched/ 已 selected proxy 全部保留)。§5.4.3 详述store-selected且bucketNetworkPolicy存在selected/source/last_matched_network原值;首次 PUT 按恢复的 source 落入上方 PUT 对应分支store-selected=false;或store-selected=true但bucketNetworkPolicy不存在)source=unknown/last_matched_network=nil;过 provider barrier(详见本表下方"分支 B provider 屏障的实施契约")之后,对每组按 matched=<none>评估一次(走default/no_change_no_default/missing_target分支)——若 default 在候选中则 Set,否则missing_target保持proxies[0]等待首次 PUT 重试。§5.8.2 "不主动重新求值 provider 变化"自 barrier 过后生效default/no_change_no_default/missing_target(内部评估,不通过 REST 返回)source与last_matched_network的值;以缓存的 ctx 强制进入评估路径(跳过unchanged_network/manual_locked短路)matched/already_selected/default/no_change_no_default/missing_target之一关键不变式:即使原
source=manual,matched 变化也走 auto 接管(manual_locked仅在"matched 未变 + source=manual"时出现)。关键用例:office 下 manual=us,回家切 DIRECT。分支 B provider 屏障的实施契约(消除实施者 re-spec 需要):
粒度:按组独立屏障。每个带
network-policy的 select 组只等待本组引用的 provider(通过use:/include-all-providers/include-all展开命中的 provider 节点;注意include-all-proxies只展开顶层静态 proxies,不引入 provider 依赖,不参与本屏障作用域)。静态 proxy 组(无 provider 引用)在启动即评估,不被其他组的慢 provider 拖慢。不采用全局屏障——避免 "A 组静态,B 组引用慢 provider" 场景下 A 组被迫等待单个 provider "首次完成" 判定:只要 provider 的
proxies[]至少 populated 过一次(通过任一途径)即视为完成:path:或默认缓存位置同步 load,load 成功即 populated;后续异步 fetch 刷新不影响屏障)—— 这对订阅型用户在断网或慢网络下启动尤其重要,避免 fetch 未完成白白等足 15salive标志,不影响候选集是否 populated)首次 populate 失败:外部 provider 既无本地缓存也首次 fetch 失败时,按 provider 自身的重试策略持续重试;屏障按下文超时兜底
first-load 超时:组级超时,统一兜底 15 秒(内部常量,不暴露为 YAML 字段;未来如有用户反馈可提升为
network-policy-first-load-timeout顶层字段)。超时到达后即使该组仍有 provider 未完成也执行评估——未完成 provider 引用的 proxy 在候选集中暂不可见,会走missing_target分支(§5.8.2),provider 后续可用时依赖下次 PUT 自愈屏障期间 PUT 到达的处理:每组单独维护一个
startup_eval_pending标志,语义是 "是否还需要在屏障解除后用 provider-ready 的候选集再跑一次启动评估",分支 B 初始化时全组置 true。屏障期间如果 host 发来PUT /network/context,立即按source=unknown强制完整评估(与正常分支 B 首次 PUT 同路径,不阻塞 REST 响应);评估后按以下规则更新标志:matched/already_selected/default/no_change_no_default(状态机给出明确的最终选择)→startup_eval_pending=falsemissing_target(因 provider 未 ready 被拦下的"疑似待重试")→startup_eval_pending保持 true,等屏障解除后用 provider-ready 的候选集重试一次屏障解除时:
startup_eval_pending=true的组:missing_target时缓存的 ctx 仍在)→ 用缓存 ctx 跑一次完整评估(走 §5.6.4 触发源 1;provider 此时已 ready,大概率不再missing_target)<none>评估一次(走 §5.6.4 触发源 2)startup_eval_pending=false的组跳过这个设计同时解决三个陷阱:(a) REST 阻塞导致 host 超时重试;(b)
source=unknown在missing_target后保持不变被误判为"未被 PUT 覆盖"导致硬编码 matched=<none>覆盖真实 ctx;(c) sticky 模式 + 屏障期missing_target→ 组永久停在错误选择(startup_eval_pending保留 true 确保 provider ready 后用缓存 ctx 重放,不依赖后续 PUT)。屏障解除后仍未自愈的missing_target(例如 provider 彻底失败),按 §5.8.2 "下次 PUT 重试"契约由后续 PUT 承担headless + 慢网络:若 first-load 超时时外部 provider 仍未 populated(既无本地缓存也 fetch 失败),
defaultproxy 若来自该 provider → 永远missing_target直到下次 PUT;headless 场景无 PUT → 降级为proxies[0]。这是已知降级,用户可通过把default设为静态 proxy(非 provider 来源)或加大 first-load 超时(未来开放字段后)缓解屏障未解除期间发生热重载:按新 YAML 的 provider 引用重置屏障计时(15s 重新开始),原屏障期间挂起的内部评估取消;屏障期间的
startup_eval_pending标志按新 YAML 的带network-policy组集合重建(新加的组置 true;删除的组丢弃;组名相同且保留的组 —— 按热重载时刻的startup_eval_pending值原样继承,包括屏障期 PUT 已落入missing_target保持 true 的情况)。理由:热重载可能改 provider 引用,按旧 YAML 继续等待会与新 YAML 的候选集不一致分支 A 部分组缺 entry 的处理:若
bucketNetworkPolicy存在但部分带network-policy的组缺 entry(例如用户新加了一个 select 组),缺 entry 的组按source=unknown/last_matched_network=nil初始化;有 entry 的组正常恢复。缺 entry 的组在首次 PUT 到来时走"该组 source=unknown 强制完整评估"分支。整体仍属分支 A。关键用例:用户上次在 office Wi-Fi 下把
auto组从hk手动改成了us。重启后:auto组:selected=us, source=manual, last_matched_network=officeus(手动被尊重)✓DIRECT5.6.3 状态管理落点与并发模型
手动 PUT、network PUT、TTL timer、热重载都会并发读写
selected/source/lastMatched/ cachefile,需要明确串行化:NetworkPolicyManager持有 context、ttl timer、每组状态selector.Set()→ 状态更新 → cachefile 写盘PUT /proxies/:name不直接写 cache,而是经过 manager(或由 manager 在 hook 里感知并同步状态)状态持有的 defensive copy:
Manager.PutContext必须对以下字段做 deep-copy,否则调用方(REST handler)复用 slice / 指针会污染内部状态:ctx.Interfacesslice(新分配)SubnetssliceMetered *bool指针DNSSuffix []stringTTL *int指针(通常 REST handler 已转为expires_at不再存原指针;若保留则需复制)推荐实现:deep-copy 后在 Manager 内部再跑一次
NormalizeAndValidate(),自愈所有 derived 字段(subnetsParsed/gatewayIPParsed);防御性一步到位,比手动逐字段维护列表更稳。kernel 端 PUT 路径优化(不短路状态机,只减少副作用):
Fingerprint 定义:对**归一化后 context(§5.4.5 规则处理完毕,不含 ttl)**的稳定序列化取 fnv64a;用于两类决策(不做 "同 fingerprint 直接返回缓存 applied" 的短路——那样会破坏
manual_locked/missing_target/ 手动 PUT 后的正确评估):TTL 续约轻量路径:触发条件(五者同时满足):
ttl字段(即本次为 TTL 模式而非 sticky)ttl(即上次也是 TTL 模式)——判定方式:cached_expires_at != nil(sticky 模式下cached_expires_at为 nil;TTL 模式下 manager 存expires_at而非原*int ttl指针,参见下文 defensive copy 段)network-policy的 select 组都没有"待重试的missing_target"——即上次评估时没有任何组落入missing_target等待下次 PUT 重试五者满足时 → 仅刷新
expires_at,不进 manager 串行队列、不重评估、不写 cachefile。这条轻量路径服务于 "TTL 模式下的续约心跳"。响应内容构造规则(保持与 §5.4.2 完整响应同 schema,host 无需区分轻量/完整路径;wire 编码规则见 §5.6.4):
matched_network:回显 manager 内缓存的最近一次评估命中值(内部<none>按 §5.6.4 编码为 wire 上的 JSONnull)applied[]:每组按当前source直接构造,不触发评估:source=manual→reason=manual_lockedsource=auto→reason=unchanged_networksource=unknown→ 不会出现(条件 (d) 保证无 pending missing_target,且分支 B 启动评估已完成;若仍unknown说明是 bug)。实施 fallback:若实际遇到(防御性),本次 PUT 降级为走完整 manager 评估 + warn 日志,不 panic——避免把轻量路径的内部不变式演化成 crashchanged=false,applied_proxy取本组当前selected,target_proxy按 policy 求值填(与完整路径一致)expires_at:返回新的now + ttl其他所有情况都走完整 manager 评估(由状态机内部
unchanged_network/manual_locked短路处理幂等,overhead 极低)。特别注意:ttl)即使 fingerprint 相同 → 走完整评估missing_target(来自上次评估)→ 走完整评估,让 §5.8.2 "下次 PUT 即使 matched 相同也重试" 的契约生效;即使 host 以 TTL 心跳周期性发同 ctx,provider 恢复后也能自愈ttl值相同 → 其他条件满足时仍走轻量路径(只是expires_at = now + ttl重新计算后值可能一样,这不影响幂等性)实施建议:manager 内维护两个位:
has_pending_missing_target_retry:每次完整评估完成后按"本轮是否有任一组落入 missing_target"更新candidate_set_dirty:MUST 在组成员列表变化(热重载)时置 true;MAY 在任一 select 组引用的 provider 触发 update 事件(订阅刷新 / 外部 provider 重新 load)时置 true。下次完整评估完成后清回 false。注意:health-check 只刷新alive标志,不改动候选集,不是candidate_set_dirty的触发源(见 §5.8.2)。只做 MUST 的降级面:若实现只 hook 热重载、不 hook provider-refresh,那么用户同时满足 "引用 provider 的 select 组 + 带 TTL 心跳 + fingerprint 稳定 + provider 动态刷新候选集" 时,light path 可能跳过对新候选集的重检,直到下次 fingerprint 变化 / 热重载 / sticky 模式切换才看见。这是设计上接受的 corner case:provider-heavy 的 host 在 TTL 心跳下优先取 sticky 模式(省略
ttl)即可完全回避该窗口;后续若有用户反馈,实现方侧 MAY 扩展为在 provider-update 钩子里调onCandidateSetDirty,无需动契约。两位任一为 true 都必须走完整评估。作为保守替代:对任何引用 provider 的
network-policy组直接禁用 TTL 快路径——更简单但 overhead 略高(有 provider 引用的组每次 TTL 心跳都进 manager)。并发协议:轻量路径不进 manager 串行队列,但要读
cached_fingerprint/cached_expires_at/has_pending_missing_target_retry/candidate_set_dirty四处 manager 内部状态。这四个字段由 manager 串行队列内 atomic store 写入,轻量路径用 atomic load 读取;字段之间不强制原子组合——任一字段短暂过期最坏退化为多走一次完整评估(由 manager 内部unchanged_network/manual_locked短路吸收),无正确性影响。Cachefile 写盘条件:manager 评估完毕后,仅当
source或last_matched_network实际变化时才触发 bucketNetworkPolicy 写盘。这与幂等短路独立——即使 fingerprint 相同,若上次是manual而这次仍是manual(同 matched),状态未变,不写;反之 manual_locked 触发source不变 / last_matched 不变时也不写每次 PUT 无论 fingerprint 是否相同,都经过 manager 状态机评估(除上述 TTL-only 续约外)——让 manual/auto/missing_target 各分支都能在每次 PUT 时重新被纳入判定。
host 侧的 debounce 契约见 §5.3(OS 网络事件触发后的 2-5s 去抖窗口,这是 host 采集节奏的权威规定)。kernel 端为防御极端 host 额外推荐 per-PUT 间隔下限不小于 1s(仅作为 warning 日志阈值,不强制拒绝请求),与 §5.3 是分层关系不是重复定义。
5.6.4
network-policy.default的角色default是保留键,承担"存在 ctx 但matched=<none>(含PUT { interfaces: [] })"或"ctx 命中某 network N,但 Mapping 对 N 无 target"这两类 fallback:default: <proxy-name>no_change_no_default:保持当前选择default: REJECTdefault 触发条件(唯一权威):
default/no_change_no_default分支仅在以下两类触发源下产生——其他路径(DELETE、TTL 过期、热重载+无 ctx、冷启动分支 A 恢复 cachefile 等)一律不触发:触发源 1:有 ctx 的状态机评估
<none>"或"Match 命中 N 但 Mapping 无 key"分支PUT /network/context评估;②热重载 + 有 ctx 的强制求值(§5.8.3);③provider barrier 期间到达的 PUT 立即评估(§5.6.2 barrier 期间 PUT 规则);④barrier 解除时对仍startup_eval_pending=true组的内部补评估(若此时有缓存 ctx)触发源 2:冷启动分支 B + 无 ctx 的内部评估
store-selected=false;或store-selected=true但bucketNetworkPolicy不存在),provider barrier 解除时 kernel 内无缓存 ctx(屏障期间 host 未发过 PUT)startup_eval_pending=true的组按 matched=<none>内部评估显式不触发 default 的路径:
DELETE /network/context后 ctx=nil:不评估、不走 default(§5.4.3)store-selected=true且bucketNetworkPolicy存在):按恢复的source落入对应分支(含manual_locked)内部哨兵与 wire 编码对照(唯一权威):
文档在状态机章节频繁使用
<none>与nil作为matched_network/last_matched_network的内部哨兵;这两者在 wire(REST 响应)上的编码规则如下:"office")"office"<none>(内部哨兵)nullnil(内部哨兵)last_matched_network可能是该值;matched_network不会是 nil)null关键规则:
"<none>"字面量——所有 "无命中" / "从未评估" 状态都编码为 JSONnullselection_source字段判断:source=unknown+last_matched_network=null→ 从未评估(内部nil);source=auto+last_matched_network=null→ 已评估无命中(内部<none>);source=manual+last_matched_network=null→ 二义,既可能是"首次PUT /network/context之前就用PUT /proxies/:name手动切换"(HandleManualSet不推进last_matched,内部仍是nil),也可能是"已评估无命中后又被用户手动切换"(内部<none>)。host 一般不需要在 wire 层完全反推这两种情形;若必须区分,应结合自身交互上下文内置 outbound 作为 target:
DIRECT/REJECT/REJECT-DROP/PASS等作为network-policytarget(包括default),必须在本组当前候选集中可见——即出现在proxies:显式列表、或被include-all-proxies/include-all展开纳入。parse-time 按本组静态候选集(§5.8.1 的 "StaticProxies" 概念)校验,任一可见路径都能通过。命名限制:
networks:里任何一条的name不得取值为default或<none>,启动时报配置错误。5.7 平台差异化指南
所有平台共享同一 REST 契约;差异在推送时机与接口枚举策略。
5.7.1 macOS
NWPathMonitor/SCDynamicStore(网络变化)+NSWorkspaceDidWakeNotification(唤醒)getifaddrs+SCDynamicStore列State:/Network/Service/*取 per-service ifaceState:/Network/Global/DNS → SearchDomains(系统聚合)5.7.2 Windows
NotifyIpInterfaceChange+INetworkListManager::NetworkConnectivityChanged+WM_POWERBROADCAST / PBT_APMRESUMEAUTOMATICGetAdaptersAddresses(主源)WlanQueryInterface(wlan_intf_opcode_current_connection))GetAdaptersAddresses的SuffixSearchList(优先)+DnsSuffix(fallback)5.7.3 Linux 桌面
RTMGRP_LINK / RTMGRP_IPV4_IFADDR / RTMGRP_IPV4_ROUTEENOBUFS并主动RTM_GETLINK/GETADDRdump 重同步/etc/resolv.conf→ NetworkManagerRT_TABLE_MAIN;wg-quick风格的 WireGuard 把默认路由写入 table 51820,main 表里不会出现 wg0 的 default route → sampler 给 wg0 的gateway_ip会持续为空 → 用户写match: { iface-type: vpn, gateway-ip: "10.7.0.1" }会永远不命中且无报错。用户应改用match: { iface-type: vpn, dns-suffix: corp.example.com }(填写 sampler 实际上报的完整 dns_suffix 项;dns-suffix是精确 token 交集匹配)或{ iface-type: vpn, name: wg0 }这类不依赖 gateway_ip 的组合。policy routing 多表的完整覆盖是 future work(见 §5.4.7 c)5.7.4 Android(FlClash 等壳)
ConnectivityManager.NetworkCallbackACCESS_FINE_LOCATION+ 位置服务开启PUT /network/context5.7.5 Headless / 路由器 / 服务器
5.8 配置校验与热重载
5.8.1 启动 / 热重载校验
内核在 config parse 阶段(启动 / 热重载)对
networks:/network-policy:做校验。由于 mihomo 的 provider 在ApplyConfig()之后 才加载,校验规则按目标来源拆分:proxies:显式列出的代理名include-all-proxies: true展开后的顶层 proxy 名use:/include-all-providers/include-all展开的 provider 节点reason=missing_targetnetworks:里有 network 未被network-policy引用(孤儿)networks:里任一name取值为default或<none>network-policy的 key 不在networks:定义的 name 列表中(且非default)metered字段ssid: []/any: []等)select类型组(url-test/fallback/load-balance等)出现network-policy:字段network-policy是select组专属字段;其他类型不支持手动选择语义)5.8.2 Provider 运行时变化
Provider 可能在运行时更新(订阅刷新、外部 provider 重新 load;health-check 只刷新
alive标志,不改动候选集)。PUT /network/context到来时,需要切到的目标代理不在本组当前候选中,跳过该组的切换,返回reason=missing_target;不更新last_matched_network,保证下次 PUT(即使 matched 相同)仍会尝试重新切换proxies:声明中,与 health-check 的 alive 标志无关——即使 target 当前 dead(health-check 失败),仍会被selector.Set()成功;死节点不会触发missing_target或重评估candidate_set_dirty(§5.6.3 的 MAY 条款);完整评估路径(sticky 模式、fingerprint 变化、热重载)下该契约始终成立5.8.3 配置热重载
mihomo 的
ApplyConfig()(响应PUT /configs/ SIGHUP)重建所有 proxies。对 network-policy 状态机的影响:select组:保留 其selection_source与last_matched_network两个 状态变量值(仅作为下一轮评估的输入参考,不蕴含"跳过评估")select组:按 §5.6 启动规则初始化(source=unknown)select组:状态一并丢弃network-policy的组——这是与"普通 PUT" 的关键区别:unchanged_network/manual_locked短路,强制走 §5.6.2 上方各 PUT 评估分支(matched/already_selected/default/no_change_no_default/missing_target)。理由:用户改 YAML 里的 policy 时 ctx 指纹没变,若不强制重算则改动看不到效果source与last_matched_network按各分支正常更新(包括"manual 被 auto 接管"——若新 YAML policy 求得的 target 不同于当前手动选择,仍以 auto 流程接管)source=manual且新 policy 求得的 target 恰好等于当前选择,按上文"正常更新"会走already_selected路径令source=auto,视作用户把手动选择写进了新 YAML policy,等同追认 auto 控制权——这是故意为之,理由是用户编辑 YAML 本身就是一次显式声明"我希望这个代理在该 network 下由 policy 决定"。如果用户想在热重载后保留手动选择,需在 YAML 里避开把当前手动选择作为该 network 的 mapping target(或不给该组配置network-policy)简单说:"保留状态变量值" 仅指"不重置为 unknown";"强制重算" 仍照常发生。这两条不矛盾,前者是评估的 input,后者是评估本身。
config reload 与
tun.device:tun.device在 config reload 后可能变化(profile 切换导致)。需区分三种 reload 路径:host 触发 kernel reload(典型:CVR UI "Apply Profile" →
PUT /configs→ mihomo reload):host 明确知道自己发起了重载,配合 §5.4.7(a) "host 自身配置重载成功后" trigger——host 在 reload 返回后强制 refreshGET /configs取新tun.device,并立即重采样;fingerprint 若变则走正常 PUT 幂等路径host 内部设置变更(纯 CVR 内部 setting,不触发 mihomo reload):
tun.device不会变,但仍走"host 自身配置重载"trigger——refresh 后 99% 情况下名字未变、缓存命中即返回,无副作用kernel 自发 reload(SIGHUP / 其他客户端直接调
PUT /configs):host 完全无感知;若发生在 Known(n) 状态下 +tun.device变了 → host 会按旧名过滤,短暂产生"旧 iface 被过滤、新 iface 未过滤"的错误。缓解:§5.4.7(a) "Manual / OS 网络事件" 触发器下建议 Known(n) 状态也加 "距上次 refresh > N 分钟(建议 5 min)则懒式 refresh" 的兜底(对称于 Unavailable 的 60s 重试)——overhead 极低(localhost HTTP GET),能把错误窗口收敛到 ≤ N 分钟详见 §5.4.7 (a)。
6. 对两个项目的具体影响
6.1 对 mihomo
收益
成本
store-selected的成本不带来的麻烦
6.2 对 CVR
收益
network-interface/sysinfo-plugin栈合拍成本
不带来的麻烦
7. 兼容性与生态考虑
7.1 配置向后兼容
networks:/network-policy:—— 行为与现在完全一致version固定为 1,未来 schema 演进走 additive + 宽容忽略策略7.2 对其他 GUI 的友好性
PUT /network/context"即可接入7.3 Headless / 路由器部署
store-selected=false;或store-selected=true但bucketNetworkPolicy不存在):过 provider barrier 后 "按 matched=<none>评估一次"——network-policy有default且 default proxy 在候选中 → 切到 default;无 default →no_change_no_default保持proxies[0]store-selected且bucketNetworkPolicy存在:恢复 cachefile 中的selected/source/last_matched_network,不跑启动评估;运行时不 PUT 的话,selected 停留在上次 cachefile 的值(此时想让 network-policy 的 default 立刻生效,可手动调一次PUT { version: 1, interfaces: [] }触发评估)8. 候选替代方案与排除理由
8.1 纯 GUI(方案 A)
排除理由:规则离开 YAML。核心 clash 生态约束的破坏。
8.2 纯内核(方案 C)
排除理由:mihomo 边界污染 + Go+CGO 跨平台代价。
8.3 把规则推送放在 mihomo 订阅机制里
例:"订阅服务自动提供网络策略"——技术上可行,但让订阅提供者能控制用户在不同网络下的流量行为,有严重信任 / 安全顾虑。
8.4 独立的 "network-agent" 进程
排除理由:用户体验上多一个组件要安装维护;对于非 CVR 用户仍没有 GUI;没有解决任何新问题。
8.5 基于 clash hosts 或 script 扩展
例:用现有的 script 规则 / RESTful hook 触发切换。排除理由:语义不对——script 是基于连接评估的,不是基于系统状态变化的。
8.6 单 primary interface 模型
另一条常见思路是让 host 只上报"当前主接口"一张 iface,kernel 按这一张 iface 的属性匹配。此方案在手机侧(iOS Stash 等)可行,但桌面场景下放弃。理由:
iface-type: vpn在单 primary 模型下永远匹不到用户装的 VPN(被物理优先 fallback 隐藏了)多接口集合 + ∃iface 语义 + rule 顺序 first-match 组合,在不增加心智负担的前提下干净地解决所有上述场景。
9. 附录:端到端示例
9.1 用户配置(mihomo YAML)
关键:rule 顺序即优先级。
home-with-corp-vpn必须放在corp-vpn-anywhere之前——否则"家里 Wi-Fi + 公司 VPN"的组合会先被更宽的corp-vpn-anywhere吃掉。9.2 运行时流
9.3 异常路径
DELETE /network/context;状态机保留(不走 default)——下次启动 + 首次 PUT 到来时按保留的source/last_matched_network走 §5.6.2,特别是"同一 network 下继续尊重用户上次的 manual 选择"(§5.4.3)tun.device=""且GET /configs失败):sampler 降级为"不过滤",mihomo 自己的 TUN 以iface_type: vpn出现在interfaces[];用户可 YAML 写not: { name: ... }手动排除Beta Was this translation helpful? Give feedback.
All reactions