我只是想上个网,为什么还要研究这些?

折腾代理客户端,经常会遇到一种很微妙的体验:

软件装好了,节点也有了,但离“正常使用”还差一段距离。

下载客户端
  ↓
导入订阅
  ↓
订阅格式不兼容,找个转换服务
  ↓
选核心、换版本
  ↓
开系统代理,发现有些程序不跟
  ↓
开 TUN,发现国内网站变慢
  ↓
调整 DNS、FakeIP、分流规则
  ↓
换一台电脑,再来一遍

不是这些客户端没有功能。恰恰相反,很多客户端的功能已经很丰富。

但对我来说,问题在于:功能都有,不代表它们已经组成了一条完整的使用路径。

下载核心是一件事,转换订阅是一件事,注册系统服务是一件事,处理 DNS 又是另一件事。每个环节都能找到工具,最后负责把它们接起来的人,还是用户。

GUI 把配置项变成了按钮和下拉框,但用户仍然需要理解这些配置项之间的关系。

我想要的东西比较直接:

  • 换一台机器,一行命令或者双击安装包,就能把运行环境准备好。
  • 订阅是什么格式,尽量交给程序识别和转换。
  • 核心可以升级,也可以固定版本,不和界面绑死。
  • 国内访问尽量保持原来的网络路径,需要代理的流量自动进入核心。
  • 出问题时,能看出卡在哪一层,而不是面对一个没有解释的“连接失败”。

于是有了 Sempre

它不是一个新的代理协议实现,而是一个跨平台的代理核心管理平台。


先用起来:一行命令,或者双击 bundle

Linux 和 macOS:

curl -fsSL https://sempre.run/install | sh

Windows PowerShell:

irm https://sempre.run/install.ps1 | iex

如果希望第一次安装时就配置好核心和订阅,也可以一起传进去:

curl -fsSL https://sempre.run/install | sh -s -- \
  --core='sing-box@stable' \
  --subscription='https://example.com/your-subscription'

官网提供了命令生成器,不需要自己记参数。

这条命令背后做的事情,不只是下载一个可执行文件:

识别操作系统和架构
  → 定位发布版本
  → 下载对应 bundle
  → 校验 SHA-256
  → 安装程序与资源
  → 注册并启动原生系统服务
  → 打开 Web 控制台

带订阅安装时,还会继续完成订阅更新、配置生成和核心启动。没有订阅、也没有已有配置时,服务先保持待配置状态。

这里的“一键”,是把安装编排交给程序,不是省掉必要的管理员授权,也不是凭空提供代理节点。

不想运行在线脚本,就下载完整包

Releases 下载对应平台的 bundle,解压后运行安装入口:

平台 安装入口
Windows 双击 install.cmd
macOS 双击 install.command
Linux 运行 install.sh,桌面环境也提供 install.desktop

bundle 带着程序、官方 Web UI 和核心资源,适合不想从零下载依赖的场景。订阅更新和实际联网仍然需要网络。

在线脚本和 bundle 最终进入的是同一套安装逻辑。重复执行可以用于安装、修复或升级,正常安装路径会保留已有配置。

安装之后,真正长期运行的是系统服务:

平台 服务管理
Windows Windows SCM
Linux systemd
macOS launchd

浏览器只是控制台。关闭网页,不等于关闭代理;停止代理核心,也不等于失去管理界面。

这一点很重要。管理工具不能在被管理的东西坏掉时,也一起消失。


Sempre 管配置和生命周期,核心负责真正转发

先把边界说清楚:

Sempre 不实现自己的 TUN,也不重新实现 sing-box 的代理协议。

TUN、协议握手、连接转发、业务流量路由,仍然由外部核心负责。Sempre 负责把它们安装好、配置好、启动起来,并管理外围的系统集成。

flowchart TB
    UI["浏览器 / CLI"] --> Manager["Sempre 管理服务"]
    OS["SCM / systemd / launchd"] --> Manager

    subgraph Control["Sempre"]
        Manager --> Profile["订阅、规则与 Profile"]
        Profile --> Compiler["配置编译器"]
        Compiler --> Validate["使用目标核心校验配置"]
        Validate --> Supervisor["暂存、启动与运行监督"]
        Manager --> Integration["系统 DNS 与路由集成"]
    end

    Supervisor --> Core["sing-box / Mihomo"]

    subgraph Data["外部核心"]
        Core --> Inbound["TUN / TProxy / 本地代理入口"]
        Inbound --> Route["业务流量路由"]
        Route --> Outbound["直连 / 代理 / 私网连接器 / 拒绝"]
    end

Sempre 自带一个 Rust DNS 前置解析器,后面会展开讲。但它处理 DNS,不接管代理核心的数据转发职责。

这种分工带来一个很实际的结果:界面、配置模型和核心版本,可以分别演进。

订阅格式,不应该决定你必须用哪个客户端

Sempre 的转换逻辑并不是从零凭空长出来的。它整合了我之前在 OhMyWrt Toolbox 中积累的订阅转换能力,后来统一到了 Rust 转换核心。

现在,一份 Profile 可以组合多个订阅地址、原始订阅文本和手工节点。

输入可以是 Clash YAML/JSON 代理列表,也可以是常见的 URI 或 Base64 订阅;节点协议覆盖 VMess、VLESS、Shadowsocks、Trojan、Hysteria、Hysteria 2、TUIC、AnyTLS 等。

转换过程可以理解成:

不同来源、不同格式的订阅
            ↓
      统一的节点与配置模型
            ↓
   合并规则、代理组和 DNS 策略
            ↓
   根据核心、版本和平台生成配置
            ↓
      用实际核心二进制校验

不是拿到一份 YAML,再机械地换成 JSON。

节点能不能表达、字段在目标版本里有没有意义、某个功能在当前平台能不能用,都属于转换的一部分。

也需要区分两种“支持”:

能力 含义
配置转换 能生成某种核心或客户端需要的格式
完整托管 能安装、校验、启动、停止、控制和诊断这个核心

目前完整托管的稳定适配器是 sing-box 和 Mihomo。转换输出覆盖的格式更多,但不能因此宣称所有对应核心都已经完整托管。

对 sing-box 的适配,不能停留在“支持 JSON”

sing-box 的灵活性很强,版本之间的配置语义也会变化。

比如 DNS 的旧版写法、新版独立 DNS server,以及 1.14 的响应匹配语义,并不是同一份模板换个版本号就能解决的。

Sempre 按核心版本和平台选择编译目标,覆盖 sing-box 1.11~1.14 的配置代际。新版能力和旧版兼容分别处理,再交给实际安装的核心检查。

核心管理也有明确的版本语义:

sempre core install sing-box@stable
sempre core use sing-box@stable

stable 是最新正式发布,不会偷偷切到预发布版本。需要测试版时,可以明确指定版本;需要保留某个版本时,也可以固定它。

安装新核心与切换正在使用的核心,是两个动作。

我不希望“下载了一个新版本”,就等于“现在立刻拿它接管我的网络”。


分流首先要回答:这个连接究竟经过哪里?

讨论自动分流,很容易从“国内直连,国外代理”开始。

这句话作为目标没问题,作为实现还远远不够。

因为访问一个网站,至少涉及两条链路:

链路 回答的问题
DNS 解析 这个名字对应什么地址,由谁来解析?
业务连接 拿到地址之后,这个连接从哪里出去?

DNS 查询经过代理,不代表业务连接一定经过代理。

DNS 返回真实 IP,也不代表业务连接一定直连。

两条链路需要配合,但不能混为一谈。

先看已经进入 sing-box 的流量

在当前托管配置中,私网连接器、私有地址、优先规则集和用户显式规则,有各自的优先级。不能因为增加了“自动国内分流”,就把这些已有规则挤到后面。

排除 DNS 接管等入口处理后,可以把主要业务决策简化成:

flowchart TD
    Input["进入 sing-box 的业务连接"] --> Explicit{"命中更高优先级规则?"}

    Explicit -- 是 --> Selected["使用对应的直连、代理、私网或拒绝动作"]
    Explicit -- 否 --> Domain{"命中国内域名集?"}

    Domain -- 是 --> Direct["直连"]
    Domain -- 否 --> Resolve["尚未解析的域名<br/>通过 remote DNS 获取真实地址"]

    Resolve --> GeoIP{"命中国内 IP 集?"}
    GeoIP -- 是 --> Direct
    GeoIP -- 否 --> Other{"命中后续普通规则集?"}

    Other -- 是 --> RuleOutbound["使用规则指定的出口"]
    Other -- 否 --> Final["使用配置的最终出口"]

这里有两层判断:

  • 域名判断:已经知道是国内域名,不需要先绕去远程解析一遍。
  • 地址判断:域名表没有覆盖,但真实地址属于国内,仍然有机会直连。

这让分流不必完全依赖一份永远追不完的域名清单。

FakeIP 不能直接拿来判断 GeoIP

这是最近一次修复里很值得讲的细节。

假设应用拿到的是:

some-site.example → 198.18.0.42

这个地址只是核心分配的 FakeIP,不是网站真实服务器地址。

核心可以从映射中恢复出 some-site.example,但:

恢复出了域名 ≠ 已经拿到了真实 IP

如果直接把 FakeIP 拿去匹配国内 IP 集,当然判断不出网站真正的位置。

所以,对走到这一阶段、仍未解析的域名,需要先解析真实地址,再执行 GeoIP 判断。

为什么这里不能随手用本地 DNS?

因为 sing-box 的 resolve 不是一个纯粹的“查询辅助信息”动作。

它会把请求目的地从域名解析成 IP。显式指定 server 时,还会绕过普通 DNS 路由选择,直接使用这个解析器。sing-box 的动作定义

如果为了判断国内地址,先向不可信的本地解析器查询:

未知域名
  → 本地 DNS 返回错误地址
  → 没命中国内规则
  → 最后选择了代理

表面上看,最后还是代理。

问题是,代理可能拿着前面那个错误地址去连接。

“出口选对了”,并不能纠正一个已经被污染的目的地址。

因此当前设计是:

已知国内域名 → 直接处理
剩余域名     → resolve(remote) → GeoIP 判断

并且让不同 DNS server 使用独立缓存,避免旧版核心把本地解析器缓存过的答案,拿来满足一次明确要求远程解析的查询。

这两个条件要一起成立:

解析器选对,缓存也不能串。

代价也很明确:未知的国内域名,经过远程 DNS 后可能得到境外 CDN 地址,最终仍然走代理。

它不是完美的地理位置识别器。但比起为了提高“国内命中率”,把未知域名的本地答案直接变成连接目标,我更愿意保留这个边界。具体顺序与约束记录在分流设计文档里。


DNS 前置:能在核心外解决的,就不要先送进核心

前面的分流图有一个前提:

流量已经进入了 sing-box。

但国内网站为什么必须先进核心,再由核心决定直连?

这是 Sempre 后来调整 DNS 架构的起点。

当时我重新看了自己 OpenWrt 网关上的方案。值得借鉴的不是堆了多少组件,而是一个很朴素的顺序:

先识别明确的国内域名
  → 使用原始网络 DNS

其余域名
  → 再交给代理核心

Sempre 最终没有要求用户再安装一套 AdGuard Home 或 mosdns,而是复用、整理已有的 Rust DNS 能力,做成共享的前置解析器。

查询先到前置层,再决定交给谁

下面先看普通公网域名的处理,私网 DNS 留到后面:

flowchart TD
    Desktop["桌面应用"] --> System["操作系统 DNS"]
    System --> Platform["平台 DNS 接管"]
    Platform --> Front["Sempre DNS 前置层"]

    LAN["LAN 客户端"] --> Gateway["网关 DNS 入口与重定向"]
    Gateway --> Front

    Front --> Rewrite{"命中 DNS 重写?"}
    Rewrite -- 是 --> Answer["返回配置的答案"]
    Rewrite -- 否 --> HTTPS{"命中 HTTPS 记录拒绝策略?"}

    HTTPS -- 是 --> Reject["返回策略拒绝响应"]
    HTTPS -- 否 --> Custom{"命中自定义分流规则?"}

    Custom -- 直连 --> Original["原始网络 DNS"]
    Custom -- 代理 --> Core["当前核心 DNS"]
    Custom -- 未命中 --> Domestic{"命中内置国内域名表?"}

    Domestic -- 是 --> Original
    Domestic -- 否 --> Core

    Original --> Real["返回真实 IP"]
    Core --> Policy["按 Profile 处理<br/>FakeIP、远程解析等"]

内置国内域名表来自 OhMyWrt 积累的 domains-min 数据,以经过校验的快照随程序分发。

这里的“原始 DNS”,指接管之前发现或显式配置的网络解析器,不是再调用已经指回 Sempre 的系统 DNS,否则就绕成环了。

而且,前置层自己发出的解析请求,也必须有正确的物理网络出口。仅仅把目标写成某个 DNS 地址,不代表请求真的绕过了 TUN。

自定义规则,要同时影响两条链路

假设一个域名在国内表里,但你明确希望它使用代理。

如果只在 sing-box 里加业务路由,而前置 DNS 仍然给它返回真实地址,那么在后面要讲的受限 FakeIP 捕获模式里,这个连接可能根本不会进入核心。

核心里的规则写得再漂亮,也没有机会执行。

所以 Sempre 的“分流规则”会同时生成两部分:

规则模式 DNS 前置层 核心业务路由
直连 交给原始 DNS 生成直连规则
代理 交给核心 DNS 生成对应的独立代理选择组与路由

这些自定义 DNS 分流规则优先于内置国内域名表。

同一份域名策略,分别落到 DNS 和业务路由中,而不是让用户维护两份容易打架的名单。

核心应用新配置后,还可以为对应规则组切换节点。

这里说的是 Sempre 专门的分流规则功能,不是把任意核心原生规则都自动翻译成 DNS 规则。


FakeIP:让 DNS 的判断,接上操作系统的路由

现在把两条链路接起来。

FakeIP 可以理解成一个占位地址:

example.com ↔ 198.18.0.42

它不代表网站真的部署在那里,而是让应用先拿到一个可用于发起连接的地址。连接进入核心后,核心再恢复对应域名并继续处理。

Sempre 的常用 FakeIP 地址范围是:

IPv4:198.18.0.0/15
IPv6:fc00::/18

这也是 sing-box 新版 FakeIP DNS server 使用的典型配置范围。FakeIP 配置说明

单独看,FakeIP 只是一个 DNS 技术。

配合前置分流和 TUN 捕获范围,它才变成一套完整的自动分流方案。

桌面 FakeIP 模式的完整路径

下面以托管桌面 sing-box TUN、普通公网域名为例:

flowchart TD
    Query["应用查询域名"] --> Front{"前置 DNS 判断"}

    Front -- 明确直连或国内域名 --> Local["原始 DNS"]
    Local --> Real["返回真实 IP"]
    Real --> Normal["应用连接真实地址"]
    Normal --> Physical["按普通系统路由访问<br/>绕过代理核心"]

    Front -- 代理或默认路径 --> CoreDNS["sing-box DNS"]
    CoreDNS --> Fake["返回 FakeIP"]
    Fake --> Connection["应用连接 FakeIP"]
    Connection --> TUN["FakeIP 网段路由进入 TUN"]
    TUN --> Restore["核心恢复域名"]
    Restore --> Rules["执行核心分流规则"]
    Rules --> Exit["代理 / 直连 / 拒绝"]

这里最关键的不是“国内直连”四个字,而是:

对前置层已经明确直连的普通公网域名,DNS 查询和后续业务连接,都可以不经过代理核心。

另一方面,拿到 FakeIP 也不等于最终一定代理。

一个域名没有命中国内域名表,先拿到了 FakeIP;连接进入核心后,又通过真实 IP 判断为国内,仍然可以选择直连。

区别是:这种直连发生在核心内部,而不是完全绕过核心。

关闭 FakeIP,前置 DNS 仍然有价值

容易产生的误解是:

“没有 FakeIP,那前置 DNS 就没用了吧?”

不是。

没有 FakeIP 时,前置层仍然可以决定:

  • 国内域名交给原始 DNS,尽量保留本地 CDN 解析结果。
  • 其他域名交给核心的远程 DNS。

改变的是后续流量如何捕获:

托管桌面模式 国内域名解析 其他域名解析 普通业务流量捕获
FakeIP 原始 DNS,真实 IP 核心返回 FakeIP 主要捕获 FakeIP 网段
Real-IP 原始 DNS,真实 IP 核心远程解析,真实 IP 全量进入 TUN,再由核心分流

Real-IP 模式下,DNS 答案都是“真实地址”,操作系统不能仅凭它们判断哪个来自直连策略、哪个来自代理策略。

因此仍然保留前置 DNS,但让业务流量进入核心统一决策。

DNS 前置分流一直存在;FakeIP 决定能否方便地把一部分业务流量也提前分出去。

自动分流也有前提

这不是一个能猜出所有应用意图的魔法。

应用绕过系统 DNS、自带 DoH、直接连接字面 IP,或者使用切换模式前留下的 DNS 缓存,都可能不走上面这条标准路径。

在受限 FakeIP 捕获模式下,一个没有落入捕获范围的真实 IP,不会因为核心里存在一条规则,就自动被送进去。

需要更完整的流量接管时,Real-IP 全量 TUN 或相应网关模式,承担的是另一种取舍。

Linux TProxy 网关也不能照搬“真实 IP 全部绕过核心”的桌面结论:LAN 业务流量可能仍被透明捕获,再由核心判断出口。


私网访问:能解析,不代表已经进了隧道

除了公网分流,Sempre 还需要处理 WireGuard 等私网连接器。

这里同样有两条独立链路。

例如:

git.internal.example
        ↓ 私网 DNS
    10.20.0.8

DNS 成功了,能不能访问?

还不知道。

如果桌面 TUN 只捕获 FakeIP 网段,10.20.0.8 是真实私网地址,它不会自动进入核心。

所以私网访问至少要接通三件事:

flowchart TD
    Name["git.internal.example"] --> DNSRule["命中私网 DNS 后缀"]
    DNSRule --> PrivateDNS["核心经连接器访问私网 DNS"]
    PrivateDNS --> IP["返回 10.20.0.8"]

    IP --> Capture["私网 CIDR 纳入 TUN 捕获范围"]
    Capture --> Route["核心路由匹配私网 CIDR"]
    Route --> Connector["选择对应私网连接器"]
    Connector --> Service["访问内网服务"]

Sempre 会把连接器配置中的私网 CIDR 同时用于系统捕获路由和核心业务路由。

一个设置,两处用途,但职责不同:

  • 系统路由负责把连接送进核心。
  • 核心规则负责把连接送进正确的隧道。

只配置私网 DNS 后缀,不等于配置了私网访问。

只添加核心域名规则,也不等于操作系统已经知道要把那个真实私网地址交给 TUN。

很多“明明解析正常,为什么还是不通”的问题,就出在这两步之间。


好用不只是能连上,还包括出问题时能改回来

回头看 Sempre 的开发过程,很多重要调整并不是什么新功能,而是在拆掉不合理的依赖。

保存配置,不能要求网络先正常

曾经遇到过一个非常典型的问题:

网络坏了,需要修改配置
  → 保存配置时要求联网拉订阅
  → 拉取失败,保存不了
  → 新配置无法启动
  → 网络继续坏着

这是一个闭环死锁。

因此 Sempre 把“保存”和“应用”拆开:

保存 Profile
  → 持久化本地配置

更新订阅 / 启动运行时
  → 获取来源
  → 编译配置
  → 核心校验
  → 暂存并应用

保存不以远程订阅可达为前提。

同样,修改配置不应该悄悄中断当前连接。待应用的变化需要能够查看,真正替换运行中的部署,也要有明确的动作和状态。

一键安装是减少初次使用的步骤,不是把后续所有危险操作都变成自动执行。

看见红色日志,也要知道它拒绝了什么

比如 DNS 日志里的:

HTTPS → reject

它很容易被理解成“HTTPS 网站被拦了”。

但这里的 HTTPS 是 DNS 记录类型,和 AAAAA 并列:

类型 主要用途
A 查询 IPv4 地址
AAAA 查询 IPv6 地址
HTTPS 查询 HTTPS 服务的连接参数

HTTPS 记录可以携带协议和地址提示等信息,不是地址栏里的 https:// 本身。标准说明

Sempre 提供拒绝这类记录的策略,可以减少额外连接提示对预期 FakeIP 路径的干扰,但它有兼容性与功能上的代价,不能理解成“越多拒绝越安全”。

当前前置实现使用 NXDOMAIN 响应,还需要注意客户端的负缓存行为。

所以,一条 DNS 拒绝日志不能直接证明网站连接失败;看到域名返回了地址,也不能直接证明出口正确。

诊断应该能把这些阶段拆开看:

DNS 由谁回答?
  → 返回真实 IP 还是 FakeIP?
  → 连接有没有进入核心?
  → 命中了哪条规则?
  → 最终选择哪个出口?
  → TCP、TLS、HTTP 是否成功?

这比一个笼统的“节点可用”更接近真实网络。


最后:不是再增加一个需要研究的客户端

Sempre 想解决的,不是“代理工具还不够多”。

而是从安装到长期运行,中间仍然有太多需要人来拼接的部分:

  • 订阅与核心之间的格式差异。
  • 核心版本与配置语义之间的差异。
  • 操作系统服务、DNS 与路由之间的差异。
  • DNS 决策与实际连接路径之间的差异。

这些差异不会因为做了一个 GUI 就消失。

我的做法是把它们放回各自应该负责的位置:

安装器负责准备环境,转换器负责生成配置,管理服务负责生命周期,DNS 前置层负责提前判断解析路径,代理核心负责真正转发。

然后让这些部分配合起来。

普通用户看到的是一行命令、一个安装入口、一份订阅。

需要深入调整的人,仍然能看到规则、配置和实际运行链路。

我觉得这才是“一键”真正应该意味着的东西:不是把复杂度藏起来,而是把复杂度处理好。

项目:tinymins/sempre
官网与安装入口:sempre.run
完整安装包:GitHub Releases