订阅文件
源码文件:31 个 · 核对版本 Etemenanki 555b7df
Etemenanki/subscribe/Cargo.tomlEtemenanki/subscribe/src/lib.rsEtemenanki/subscribe/src/proxy_servers.rsEtemenanki/subscribe/src/route.rsEtemenanki/subscribe/src/dns.rsEtemenanki/subscribe/example-subscribe.tomlEtemenanki/subscribe/tests/unit/subscribe.rsEtemenanki/app/src/subscribe.rsEtemenanki/app/src/config.rsEtemenanki/app/src/lower.rsEtemenanki/app/src/transport.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/main.rsEtemenanki/app/src/routes.rsEtemenanki/app/src/api.rsEtemenanki/ffi/src/proxy.rsEtemenanki/ffi/src/error.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/dns/hosts.rsEtemenanki/protocols/src/hysteria/config.rsEtemenanki/protocols/src/wireguard/config.rsEtemenanki/environment/src/routing.rsEtemenanki/supervisor/src/build/dns.rsEtemenanki/supervisor/src/build/validate.rsEtemenanki/supervisor/src/build/apply.rsEtemenanki/app/tests/unit/subscribe.rsEtemenanki/app/tests/unit/config.rsEtemenanki/app/tests/unit/lower.rsEtemenanki/app/tests/unit/api.rsEtemenanki/app/tests/integration/e2e_api.rsEtemenanki/ffi/tests/proxy.rs
订阅文件是服务商交给 Etemenanki 客户端的东西:它提供的代理服务器(节点)、它建议的 DNS 设置,以及说明哪些流量走哪个节点的路由组。它是一个独立的 TOML 文件,与 app 的配置分开。etemenanki-subscribe crate 只定义它的格式,不做别的事。etemenanki-app 读取配置中 [subscribe] 一节指定的文件,先把它合并进配置,再把结果降为 spec(期望状态),这一步称为 lowering(降为 spec)。supervisor(监管器)从不接触订阅文件,只看到它变成的出站、路由规则和 DNS spec。
本页面向修改格式 crate 或 app/src/subscribe.rs 的贡献者。配置 schema 的其余部分、Sources 以及一般意义上的 lowering 见 etemenanki-app:从 TOML 到 spec。文件如何重载见 etemenanki-app:运行、重载与关停,客户端如何切换一个路由组的节点见 REST API(etemenanki-webclient)。面向运维者的订阅文件指南是另一个页面。
| 组件 | 文件 → 符号 | 负责 | 交给别处 |
|---|---|---|---|
| 格式 | subscribe/src/lib.rs → Subscribe,以及模块 proxy_servers、dns 和 route |
各个类型及其 serde 形态;拒绝未知的键和缺失的键;用 toml::to_string 把文件写回 |
所有跨条目的检查:名称唯一、inherit 指向真实存在的路由组、选择指向某个节点。解析 DNS URL。任何 I/O。 |
| 读取 | app/src/config.rs → read_sources、sources_from、sources_given |
把文件的字节与配置的字节一起放进 Sources |
解析文件 |
| 合并 | app/src/subscribe.rs → parse、apply |
解析字节;跨条目的检查;改写解析得到的 Config:节点变成出站,路由组变成规则,[dns] 变成 Config::subscribe_dns |
自带语法的字符串(UUID、密钥、PSK、CIDR、端口、正则、DNS URL) |
| 内置出站 | app/src/subscribe.rs → ensure_builtins |
在某条路由指向 direct 和 blackhole 时创建它们,无论有没有订阅文件 |
— |
| lowering | app/src/lower.rs → lower、lower_dns、lower_route、lower_outbound |
把合并后的 Config 变成 Spec。订阅文件的 [dns] 变成 DnsSpec::Split。解析 UUID、WireGuard 密钥、Shadowsocks 2022 PSK(格式和长度)、CIDR、端口、正则和 DNS URL。 |
tag 引用、TLS 材料、Salamander 密钥长度、构建解析器和路由表,这些归 supervisor(校验与应用错误) |
合并只涉及客户端一侧。每个 [[inbound]] 仍是配置自己的,配置自己的出站和负载均衡器也是。apply 不做 I/O,也不启动任何东西。它改写一个 Config 值,随后 lower 像对待任何配置一样把它降为 spec。
格式 crate
Section titled “格式 crate”subscribe/Cargo.toml 把 crate 命名为 etemenanki-subscribe,版本 0.1.1。它发布到一个私有 Cargo registry。它只依赖 serde 和 toml,不依赖工作区中的任何其他 crate。工作区里只有 etemenanki-app 依赖它(版本要求 0.1.0),FFI 经由 app 用到它。
subscribe/src/lib.rs 用 #![deny(...)] 禁止 clippy::unwrap_used、clippy::expect_used 和 clippy::panic,又用 #![cfg_attr(test, allow(...))] 为测试重新放开这三个 lint,因为测试使用的是已知正确的输入。crate 自己的文档给出了解析和写出的调用:toml::from_str::<Subscribe> 和 toml::to_string。
| Derive | 类型 |
|---|---|
Clone、PartialEq、Eq、Serialize、Deserialize |
crate 中的每个类型 |
Copy |
四个不带数据的枚举:VmessSecurity、ShadowsocksMethod、ProxyServerIpv6DnsPolicy、Network |
Default |
Subscribe(空)、Tls(没有服务器名,校验证书)、VmessSecurity(Auto)、RouteGroupInherit(Default)。Stream 手写实现了它,即纯 TCP。 |
app 的 SubscribeConfig 不属于这个 crate,只 derive 了 Deserialize、Clone 和 PartialEq。
一个完整的文件
Section titled “一个完整的文件”下面这个文件用到了全部三个部分,除 SOCKS 和 HTTP 外每种协议各有一个节点,所有值都是占位值:
[[proxy_server]]name = "vless-ws"server = "proxy.example.com"port = 443protocol = "vless"id = "11111111-2222-3333-4444-555555555555"stream = { network = "ws", path = "/v", tls = { server_name = "cdn.example.com" } }
[[proxy_server]]name = "vmess-grpc"server = "proxy.example.com"port = 443protocol = "vmess"id = "11111111-2222-3333-4444-666666666666"security = "chacha20-poly1305"stream = { network = "grpc", service_name = "GunService", tls = {} }
[[proxy_server]]name = "trojan-tls"server = "proxy.example.com"port = 443protocol = "trojan"password = "replace-with-a-long-random-password"stream = { network = "tcp", tls = {} }
[[proxy_server]]name = "ss-2022"server = "203.0.113.7"port = 8388protocol = "shadowsocks"method = "2022-blake3-aes-128-gcm"password = "AAAAAAAAAAAAAAAAAAAAAA=="
[[proxy_server]]name = "hy2"server = "proxy.example.com"port = 8443protocol = "hysteria2"password = "replace-with-a-long-random-password"obfs = { type = "salamander", password = "replace-with-a-long-random-password" }
[[proxy_server]]name = "wg"server = "198.51.100.1"port = 51820protocol = "wireguard"private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="address = ["192.0.2.2", "2001:db8::2"]reserved = [1, 2, 3]
[dns]pre_proxy = ["udp://192.0.2.53:53"]through_proxy = ["https://198.51.100.53/dns-query", "tls://203.0.113.53:853#dns.example.com"]use_hosts = trueconnect_ipv6 = "preferred"resolve_ipv6 = true
[[route_group]]name = "Ads"inherit = "blackhole"matches = [{ geosite = ["category-ads-all"] }]
[[route_group]]name = "Streaming"inherit = { group = "Proxy" }matches = [ { geosite = ["netflix"] }, { domain_suffix = ["video.example.com"] },]
[[route_group]]name = "Local"inherit = "direct"matches = [ { geoip = ["private"] }, { cidr = ["192.0.2.0/24"] },]
[[route_group]]name = "Proxy"matches = [ { domain_full = ["api.example.com"] }, { port = ["443", "8000-9000"] }, { network = ["udp"] },]这里的 Shadowsocks 2022 密钥是一个 16 字节的占位值,这是 2022-blake3-aes-128-gcm 要求的长度。真实的密钥用 openssl rand -base64 16 生成(另外两种 2022 方法需要 32 字节,用 openssl rand -base64 32)。真实的 WireGuard 私钥用 wg genkey 生成,对应的公钥用 wg pubkey 得出。配合合并示例中的配置,只要 geodata 路径指向真实文件,这个文件就能用固定版本的二进制通过 --test。
crate 自带一个示例 subscribe/example-subscribe.toml,其中有四个形态相同的路由组(它的第三个组是 Domestic,本文件中对应的是 Local)、七个节点(除 HTTP 外每种协议一个),以及每一种 inherit 写法。两套测试都用 include_str! 读取它。
Subscribe
Section titled “Subscribe”#[serde(deny_unknown_fields)]pub struct Subscribe { #[serde(default, rename = "proxy_server", skip_serializing_if = "Vec::is_empty")] pub proxy_servers: Vec<ProxyServer>, #[serde(default, skip_serializing_if = "Option::is_none")] pub dns: Option<DnsConfig>, #[serde(default, rename = "route_group", skip_serializing_if = "Vec::is_empty")] pub route_groups: Vec<RouteGroup>,}
fn is_default<T: Default + PartialEq>(value: &T) -> bool;- 三个字段都带有
#[serde(default)],所以每个部分都是可选的,空文件会解析为Subscribe::default()(这个结构体也 derive 了Default)。容器级别没有#[serde(default)]。 deny_unknown_fields拒绝任何其他顶层表,例如拼错的[dsn]。is_default是 crate 私有的辅助函数,每个skip_serializing_if = "is_default"背后都是它。它让写出的文件省略默认值,按代码注释的说法,这样文件就“和手写的一样短”。
ProxyServer 与 Protocol
Section titled “ProxyServer 与 Protocol”pub struct ProxyServer { /// What a user sees and picks for a route group. Unique within the file. pub name: String, /// Host name or IP literal. pub server: String, pub port: u16, #[serde(flatten)] pub protocol: Protocol,}
#[serde(tag = "protocol", rename_all = "snake_case")]pub enum Protocol { Vless(Vless), Vmess(Vmess), Trojan(Trojan), Shadowsocks(Shadowsocks), Socks(Socks), Http(Http), Hysteria2(Hysteria2), Wireguard(Wireguard),}一个节点就是一个 [[proxy_server]] 表。每个节点都有的键(name、server、port)与该节点协议的键位于同一个表中。protocol 选出 Protocol 的变体,由这个变体读取表中其余的键。
ProxyServer 不能带 deny_unknown_fields,因为 serde 不允许它与 flatten 同时使用。未知的键照样会被拒绝:serde 把 ProxyServer 没有认领的每个键都交给协议结构体,而每个协议结构体都拒绝未知字段。所以拼错的 pasword,或者 Trojan 节点上的 id,都会让解析失败。测试 a_mistyped_or_missing_key_is_rejected 固定了这两种情况。
protocol 接受的值恰好是八个变体名:vless、vmess、trojan、shadowsocks、socks、http、hysteria2 和 wireguard。app 出站的别名(hy2、hysteria)在这里不被接受,也没有 freedom 或 blackhole 节点。direct 和 blackhole 是合并提供的内置目标(见下文)。
键名沿用 app 的 [[outbound]] 设置,所以一个节点读起来就像它将变成的那个出站。
各变体的结构体和两个加密方式枚举如下,省略了字段上的 serde 属性:
#[serde(deny_unknown_fields)]pub struct Vless { pub id: String, pub stream: Stream }
#[serde(deny_unknown_fields)]pub struct Vmess { pub id: String, pub security: VmessSecurity, pub stream: Stream }
#[serde(deny_unknown_fields)]pub struct Trojan { pub password: String, pub stream: Stream }
#[serde(deny_unknown_fields)]pub struct Shadowsocks { pub method: ShadowsocksMethod, pub password: String, pub stream: Stream }
// `Http` has the same three fields.#[serde(deny_unknown_fields)]pub struct Socks { pub user: Option<String>, pub pass: Option<String>, pub stream: Stream }
#[serde(deny_unknown_fields)]pub struct Hysteria2 { pub password: String, pub server_name: Option<String>, pub allow_insecure: bool, pub obfs: Option<Hysteria2Obfs>,}
#[serde(deny_unknown_fields)]pub struct Wireguard { pub private_key: String, pub peer_public_key: String, pub preshared_key: Option<String>, pub address: Vec<IpAddr>, pub mtu: Option<usize>, pub keepalive: Option<u16>, pub reserved: Option<[u8; 3]>,}
pub enum VmessSecurity { #[default] #[serde(rename = "auto")] Auto, #[serde(rename = "aes-128-gcm")] Aes128Gcm, #[serde(rename = "chacha20-poly1305")] Chacha20Poly1305,}
pub enum ShadowsocksMethod { #[serde(rename = "aes-128-gcm")] Aes128Gcm, #[serde(rename = "aes-256-gcm")] Aes256Gcm, #[serde(rename = "chacha20-poly1305", alias = "chacha20-ietf-poly1305")] Chacha20Poly1305, #[serde(rename = "xchacha20-poly1305", alias = "xchacha20-ietf-poly1305")] Xchacha20Poly1305, #[serde(rename = "2022-blake3-aes-128-gcm")] Blake3Aes128Gcm, #[serde(rename = "2022-blake3-aes-256-gcm")] Blake3Aes256Gcm, #[serde(rename = "2022-blake3-chacha20-poly1305")] Blake3Chacha20Poly1305,}每个 Option 字段都带有 #[serde(default, skip_serializing_if = "Option::is_none")]。每个 stream、Vmess::security 和 Hysteria2::allow_insecure 都带有 #[serde(default, skip_serializing_if = "is_default")]。其余字段(包括 Wireguard::address)没有属性:它们是必填的,并且总会被写出。
| 协议 | 键 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 全部 | name |
字符串 | 是 | 成为出站的 tag。唯一性由合并检查。 | |
| 全部 | server |
字符串 | 是 | 主机名或 IP 字面量。IPv6 字面量直接书写,不加方括号。 | |
| 全部 | port |
u16 |
是 | ||
vless |
id |
字符串 | 是 | 用户 UUID,按原样保存,由 lowering 解析 | |
vmess |
id |
字符串 | 是 | 同 VLESS | |
vmess |
security |
字符串(枚举) | 否 | "auto" |
auto(等同于 aes-128-gcm)、aes-128-gcm、chacha20-poly1305。必须完全照此拼写:这个枚举不做大小写折叠,而 app 自己的 security 键会被 app/src/lower.rs → parse_security 转成小写,因此接受 AUTO。 |
trojan |
password |
字符串 | 是 | ||
shadowsocks |
method |
字符串(枚举) | 是 | aes-128-gcm、aes-256-gcm、chacha20-poly1305(别名 chacha20-ietf-poly1305)、xchacha20-poly1305(别名 xchacha20-ietf-poly1305)、2022-blake3-aes-128-gcm、2022-blake3-aes-256-gcm、2022-blake3-chacha20-poly1305。读取时接受别名,写出时使用规范名。 |
|
shadowsocks |
password |
字符串 | 是 | 对 2022-* 方法而言是 base64 编码的 PSK。用 : 连接的多个 PSK(iPSK:…:uPSK)表示选用多用户身份链。 |
|
socks、http |
user、pass |
字符串 | 否 | 只有设置了 user 才有账号:没有 user 时 app/src/lower.rs → proxy_account 不创建账号,所以只写了 pass 的节点拨号时不做认证,--test 也接受它。pass 默认为空。 |
|
vless、vmess、trojan、shadowsocks、socks、http |
stream |
表 | 否 | 纯 TCP | 见 Stream 与 Tls |
hysteria2 |
password |
字符串 | 是 | ||
hysteria2 |
server_name |
字符串 | 否 | 节点的 server |
SNI,也是校验证书时使用的名称。默认值由 lowering 填入。 |
hysteria2 |
allow_insecure |
bool | 否 | false |
|
hysteria2 |
obfs |
表 | 否 | 无 | { type = "salamander", password = "…" }。见 Hysteria2Obfs。 |
wireguard |
private_key、peer_public_key |
字符串 | 是 | base64 密钥 | |
wireguard |
preshared_key |
字符串 | 否 | base64 密钥 | |
wireguard |
address |
IP 数组 | 是 | 分配给本客户端的隧道地址。格式层按 IpAddr 解析,所以格式错误的地址会让解析失败。 |
|
wireguard |
mtu |
整数 | 否 | 1420 | 默认值是 protocols/src/wireguard/config.rs → DEFAULT_MTU,由 lowering 填入 |
wireguard |
keepalive |
u16 |
否 | 无 | 持久保活间隔,单位为秒 |
wireguard |
reserved |
3 个整数的数组 | 否 | 无 | [u8; 3]:恰好三个值,每个 0 到 255。某些服务商用这三个保留的头部字节区分客户端。 |
Hysteria 2 运行在 QUIC 之上,而 QUIC 总是使用 TLS,所以它没有 stream,它的 TLS 键直接放在节点表中,和 app 的设置一样。WireGuard 也没有 stream。它的对端 endpoint 就是节点的 server 和 port,所以不会出现 app 的 endpoint 键。
Stream 与 Tls
Section titled “Stream 与 Tls”#[serde(tag = "network", rename_all = "snake_case", deny_unknown_fields)]pub enum Stream { Tcp { tls: Option<Tls> }, Ws { path: Option<String>, host: Option<String>, tls: Option<Tls> }, Grpc { service_name: String, authority: Option<String>, tls: Option<Tls> },}
impl Default for Stream { fn default() -> Self { Stream::Tcp { tls: None } }}
#[serde(deny_unknown_fields)]pub struct Tls { pub server_name: Option<String>, pub allow_insecure: bool,}上面每个 Option 和 bool 字段都带有 #[serde(default, skip_serializing_if = …)],所以只有 network 和 gRPC 的 service_name 是必填的。
network |
键 | 默认值(由 lowering 填入) |
|---|---|---|
tcp,或完全没有 stream |
tls |
没有 tls 时为纯 TCP |
ws |
path、host、tls |
path 为 /。host 依次回退到 tls.server_name、节点的 server。 |
grpc |
service_name(必填)、authority、tls |
authority 依次回退到 tls.server_name、节点的 server |
当且仅当存在 tls 表时启用 TLS,空的 tls = {} 也算。这一条规则就涵盖了 app 中写作 network = "tls" 和写作 security = "tls" 的两种情形,订阅文件根本无法表达一个未知的 security 值。在 tls 中,server_name 是 SNI,也是校验证书时使用的名称,默认为节点的 server。没有 ca_file:除非 allow_insecure 为 true,证书都用系统根证书校验。
Hysteria2Obfs
Section titled “Hysteria2Obfs”#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]pub enum Hysteria2Obfs { Salamander { password: String },}obfs 是一个由 type 选择变体的表。salamander 是唯一的类型,它的 password 必填。app 中扁平的 obfs_password 键在订阅文件里不存在:a_mistyped_or_missing_key_is_rejected 会拒绝在 obfs 旁边还写了它的节点。密码按原样保存,格式层不检查它的长度。构建出站时,supervisor 拒绝短于 4 字节的密钥(supervisor/src/build/validate.rs → MIN_SALAMANDER_PSK)。
DnsConfig 与 ProxyServerIpv6DnsPolicy
Section titled “DnsConfig 与 ProxyServerIpv6DnsPolicy”#[serde(deny_unknown_fields)]pub struct DnsConfig { pub pre_proxy: Vec<String>, pub through_proxy: Vec<String>, pub use_hosts: bool, pub connect_ipv6: ProxyServerIpv6DnsPolicy, pub resolve_ipv6: bool,}
#[serde(rename_all = "snake_case")]pub enum ProxyServerIpv6DnsPolicy { Required, Preferred, Tolerated, Forbidden,}没有 DNS 建议的服务商就不写 [dns]。写了 [dns] 的服务商要写出每一个键,这样客户端永远不必猜测一个省略的键原本是什么意思。没有字段带 #[serde(default)],DnsConfig 也没有实现 Default。缺少五个键中任何一个的 [dns] 表都会让解析失败。
| 键 | 含义 |
|---|---|
pre_proxy |
直接询问的服务器,在任何代理就绪之前使用。它们解析代理服务器自己的主机名,以及 through_proxy 服务器的主机名。可以为空。 |
through_proxy |
经由代理询问的服务器。可以为空。 |
use_hosts |
在询问任何服务器之前,先用系统 hosts 文件应答 |
connect_ipv6 |
代理服务器的名称同时有 A 和 AAAA 记录时,用哪个地址族访问它:required(只用 IPv6;没有 AAAA 记录的服务器无法访问)、preferred(两者都有时用 IPv6)、tolerated(两者都有时用 IPv4)、forbidden(只用 IPv4;忽略 AAAA 记录) |
resolve_ipv6 |
是否把 AAAA 应答返回给客户端背后的应用程序 |
服务器字符串按原样保存。app 在 lowering 时用 protocols/src/dns/mod.rs → Backend::from_url 解析它们:
| 形式 | 服务器 | 默认端口 |
|---|---|---|
"system" |
主机的解析器 | — |
"udp://192.0.2.53:53" |
普通 DNS。拒绝 # 片段。 |
53 |
"tls://203.0.113.53:853#dns.example.com" |
DNS over TLS。片段是 TLS 服务器名,不能为空。没有片段时,服务器名就是主机名;主机是 IP 时,取它的文本形式。 | 853 |
"https://198.51.100.53/dns-query" |
DNS over HTTPS。没有路径的 URL 使用 /dns-query。带 userinfo(user@…)或没有主机的 authority 会被拒绝:dns: "<url>" has no usable host。 |
443 |
每种形式中的主机都写作 host、host:port、[v6] 或 [v6]:port,所以 IPv6 地址要加方括号,例如 udp://[2001:db8::53]:53。这与节点的 server 不同,后者直接写 IPv6 字面量。不加方括号的 IPv6 主机会失败,因为第一个冒号之后的文本会被当作端口:dns: server "udp://2001:db8::53": has an invalid port。
RouteGroup、RouteGroupInherit 与 RouteMatch
Section titled “RouteGroup、RouteGroupInherit 与 RouteMatch”#[serde(deny_unknown_fields)]pub struct RouteGroup { pub name: String, #[serde(default, skip_serializing_if = "is_default")] pub inherit: RouteGroupInherit, #[serde(default)] pub matches: Vec<RouteMatch>,}
#[serde(rename_all = "snake_case")]pub enum RouteGroupInherit { #[default] Default, #[serde(rename = "blackhole")] BlackHole, Direct, Group(String),}
#[serde(rename_all = "snake_case")]pub enum RouteMatch { DomainFull(Vec<String>), DomainSuffix(Vec<String>), DomainKeyword(Vec<String>), DomainRegex(Vec<String>), Cidr(Vec<String>), Port(Vec<String>), Network(Vec<Network>), Geosite(Vec<String>), Geoip(Vec<String>),}
#[serde(rename_all = "snake_case")]pub enum Network { Tcp, Udp }每个路由组把它匹配到的流量发往一个节点,这个节点由用户在客户端中选择。任何 [[proxy_server]] 都可以被选中。路由组从上到下依次尝试,第一个匹配的生效。没有任何路由组匹配的流量发往默认节点。
-
name是客户端显示的名称。它在文件内唯一,由合并检查。 -
inherit是用户为该路由组选择节点之前它所使用的目标:写法 变体 该路由组的流量发往 不写,或 inherit = "default"Default默认节点 inherit = "direct"Directdirect,不经代理inherit = "blackhole"BlackHoleblackhole:连接被静默丢弃inherit = { group = "Proxy" }Group("Proxy")路由组 Proxy发往的目标 -
matches是一个由单键表组成的列表。其中任何一项匹配,路由组就匹配这个连接;一项中任何一个值匹配,这一项就匹配。不写时为空列表,什么都不匹配。
| 种类 | 值 | 匹配 |
|---|---|---|
domain_full |
域名 | 完全相同的域名 |
domain_suffix |
域名 | 该域名或它的任何子域名 |
domain_keyword |
子串 | 域名中的子串 |
domain_regex |
模式 | 对转成小写的域名做不锚定的正则匹配。不支持环视和反向引用。 |
cidr |
CIDR,如 "192.0.2.0/24" |
目的地址。只有 IP 目的地才能匹配。 |
port |
"443" 或范围 "1000-2000" |
目的端口 |
network |
"tcp"、"udp" |
流的网络类型 |
geosite |
"code" 或 "code@attr" |
一个 geosite 列表 |
geoip |
"code",或用 "!code" 表示列表之外的地址 |
一个 GeoIP 列表 |
这些种类和值的语法与 app 的 [[route.rule]] 匹配条件相同,见路由模型。app 的 inbound_tag 和 source_cidr 没有包含进来,因为它们取决于客户端自己的设置,服务商无从得知。network 的值是枚举变体,所以 tcp 和 udp 以外的任何值都会让解析失败。其余的值是格式层不检查的字符串:lowering 解析 CIDR、端口和正则并把域名转成小写,supervisor 在构建路由时解析 geo 代码(见下文)。
serde 规则
Section titled “serde 规则”| 规则 | 位置 | 效果 |
|---|---|---|
deny_unknown_fields |
Subscribe、每个协议结构体、Tls、DnsConfig、RouteGroup,以及内部标记的 Stream 和 Hysteria2Obfs |
拼错的键是一个错误,而不是一个被悄悄丢掉的设置,和 app 配置一样 |
不用 deny_unknown_fields,改用 flatten |
ProxyServer |
未知的节点键会到达协议结构体,由它拒绝 |
| 内部标记的枚举 | Protocol 由 protocol 标记,Stream 由 network 标记,Hysteria2Obfs 由 type 标记 |
标记键与变体的键并列在同一个表中 |
外部标记的枚举,不带 deny_unknown_fields |
RouteGroupInherit、RouteMatch,以及字符串枚举 Network、VmessSecurity、ShadowsocksMethod、ProxyServerIpv6DnsPolicy |
单元变体是一个字符串("direct");带数据的变体是一个单键表({ group = "Proxy" }、{ port = ["443"] })。未知变体本身就会让解析失败。 |
| 每个键都必填 | DnsConfig |
不完整的 [dns] 会让解析失败 |
skip_serializing_if |
为空的两个顶层数组、None,以及经 is_default 判断的默认值 |
写出的文件省略默认值。其他向量总会写出:Wireguard::address、pre_proxy、through_proxy、RouteMatch 中的值列表,以及 RouteGroup::matches(为空时写作 matches = [])。 |
| 别名 | ShadowsocksMethod |
读取 chacha20-ietf-poly1305 和 xchacha20-ietf-poly1305;写出规范名 |
| 固定大小和带类型的值 | port: u16、keepalive: Option<u16>、reserved: Option<[u8; 3]>、address: Vec<IpAddr>、mtu: Option<usize> |
超出范围的数字和格式错误的地址会让解析失败 |
当解析在节点的某个协议键上失败时,错误第一行给出的位置是该节点的 [[proxy_server]] 表头,而不是这个键所在的行。缺少 [dns] 的键时,报告的位置是 [dns] 表头。
格式没有包含的内容
Section titled “格式没有包含的内容”| 没有包含 | 原因 | 节点得到的值 |
|---|---|---|
ca_file(TLS 和 Hysteria 2) |
它指向客户端上的文件,服务商无法知道客户端的文件系统 | 系统根证书,除非设置了 allow_insecure |
address_family |
它调整的是客户端,这不该由服务商设定 | 文件有 [dns] 时由 [dns].connect_ipv6 推出,否则为 auto |
max_concurrent_streams(Hysteria 2) |
同上 | protocols/src/hysteria/config.rs → DEFAULT_MAX_CONCURRENT_STREAMS = 102,400 |
endpoint(WireGuard) |
endpoint 就是节点的 server 和 port |
由这两者构造 |
stream 中的 security |
tls 是否存在已经说明了它 |
— |
inbound_tag、source_cidr 匹配条件 |
它们取决于客户端自己的设置 | — |
文件从哪里来
Section titled “文件从哪里来”配置中的 [subscribe]
Section titled “配置中的 [subscribe]”pub struct Config { // … #[serde(default)] pub subscribe: Option<SubscribeConfig>, // … /// The subscribe file's `[dns]`, once `subscribe::apply` has merged the /// file in. Never read from the config file itself. #[serde(skip)] pub subscribe_dns: Option<etemenanki_subscribe::dns::DnsConfig>,}
#[serde(deny_unknown_fields)]pub struct SubscribeConfig { #[serde(default)] pub path: Option<PathBuf>, #[serde(default)] pub routes: BTreeMap<String, CompactString>,}
impl SubscribeConfig { pub fn describe(&self) -> String;}| 项 | 含义 |
|---|---|
path |
订阅文件。相对路径以工作目录为基准,与配置中的其他路径一样。从磁盘加载配置时必须提供;前端程序直接拿到文件内容时(移动端 FFI)不读取它。 |
routes |
[subscribe.routes]:路由组名 → 承载该组流量的节点。键 default 为没有任何路由组匹配的流量选择节点。值可以是任何节点、出站或负载均衡器的 tag,"direct" 和 "blackhole" 始终可用。它是一个 BTreeMap,所以合并按键的顺序检查这些选择。 |
describe() |
在消息中指代这个文件:按原样写出的路径,没有路径时为 the subscribe file。 |
Config::subscribe_dns |
#[serde(skip)]。配置文件中的 [subscribe_dns] 表在 Config 的 deny_unknown_fields 下是未知键,所以只有合并能设置它。 |
Sources
Section titled “Sources”构建一份配置所用的字节是 Sources { config: Vec<u8>, subscribe: Option<Vec<u8>> }。它的三个构造函数决定订阅文件是否包含在内:
| 构造函数 | 使用方 | 订阅文件 | 拒绝 |
|---|---|---|---|
read_sources(path) |
Instance::start、Instance::reload、REST API 的读取 |
配置能解析且有 [subscribe] 时,从 [subscribe] path 读取 |
无法读取的配置文件,报原样的 OS 错误;其余同 sources_from |
sources_from(config) |
read_sources、instance::check_bytes(--test) |
同上,只是配置字节已经在手 | [subscribe] needs a path naming the subscribe file(InvalidInput);subscribe file <path>: <os error>(kind 取自 OS 错误) |
sources_given(config, subscribe) |
FFI 的 Proxy::start 和 Proxy::reload |
传入的字节;不读取任何 path |
the config has a [subscribe] section, but no subscribe file was given;a subscribe file was given, but the config has no [subscribe] section to pick its routes in(都是 InvalidInput) |
无法解析的配置仍然是一组 sources:构造函数把它的解析错误与字节一起返回,由 effective 报告。sources_given 的一致性检查只在配置能解析时运行。Sources 的完整说明见 etemenanki-app:从 TOML 到 spec。
effective
Section titled “effective”pub fn effective(parsed: io::Result<Config>, sources: &Sources) -> io::Result<Config> { let mut cfg = parsed?; match &sources.subscribe { Some(bytes) => { let file = crate::subscribe::parse(bytes)?; crate::subscribe::apply(&mut cfg, &file)?; } None => crate::subscribe::ensure_builtins(&mut cfg, false)?, } Ok(cfg)}effective 是加载路径上 parse 和 apply 的唯一调用方,每一次加载都经过它:--test、启动、每次重载、REST API 对编辑的检查,以及 FFI。routes::Snapshot::of 是 parse 仅有的另一个调用方,位于加载路径之外(见下文)。有订阅文件时,effective 解析并合并它。没有时,它仍会创建路由指向的内置出站,不过用的是宽松模式(见下文)。
pub fn parse(bytes: &[u8]) -> io::Result<Subscribe>;parse 先用 std::str::from_utf8 检查 UTF-8,再运行 toml::from_str。两种错误都经由私有辅助函数 invalid 变成 InvalidInput,文本前加上 subscribe: ;这个模块的每个错误都经过这个函数。
flowchart LR files["配置文件与订阅文件"] sources["Sources"] cfg["parse_bytes: Config"] subfile["subscribe::parse: Subscribe"] apply["subscribe::apply"] plain["ensure_builtins,宽松模式"] lower["lower::lower"] sup["supervisor check 或 apply"] files -->|"read_sources, sources_from, sources_given"| sources sources --> cfg cfg -->|"有订阅文件字节"| subfile subfile --> apply apply --> lower cfg -->|"没有订阅文件字节"| plain plain --> lower lower -->|"Spec"| sup
合并位于解析和 lowering 之间,只处理 Config 值。到达 supervisor 的是一个 Spec,其中没有任何东西标记某个出站是节点、某条规则来自路由组。
合并:apply
Section titled “合并:apply”pub const DEFAULT_ROUTE: &str = "default";pub const DIRECT: &str = "direct";pub const BLACKHOLE: &str = "blackhole";
pub fn apply(cfg: &mut Config, file: &Subscribe) -> io::Result<()>;apply 把 file 合并进 cfg,正是 cfg 的 [subscribe] 指定了这个文件。它先克隆 cfg.subscribe,没有时立即返回 Ok(());sources 的构造函数从不把订阅文件与没有 [subscribe] 的配置配在一起。否则它依次运行下面这些阶段,遇到第一个错误就结束合并:
flowchart TB nodes["1. 节点作为出站追加"] groups["2. 检查路由组名"] picks["3. 检查选择"] drop["4. 替换配置的规则,确定默认目标"] each["5. 解析每个路由组并转成规则"] builtins["6. 确保 direct 和 blackhole 存在(严格模式)"] dns["7. 保存文件的 DNS 设置"] nodes --> groups --> picks --> drop --> each --> builtins --> dns
- 节点。 已占用 tag 的集合先装入配置中每个出站的 tag 和每个负载均衡器的 tag。每个节点的
name按文件顺序插入。名称已在集合中时,合并失败,报node "<name>" is defined twice, or shares its name with a config outbound,与负载均衡器冲突也报这一条。否则node_outbound把节点变成一个OutboundConfig,追加到配置自己的出站之后。文件有[dns]时,它的connect_ipv6只映射一次为地址族,传给每个节点。节点排在最前,是因为之后的每项检查都要对照它们解析名称。 - 路由组。 名为
default的路由组失败,报a route group may not be named "default": [subscribe.routes] uses that key for the default node。名称已被占用的第二个路由组失败,报route group "<name>" is defined twice。路由组按名称放进一个HashMap,供继承遍历使用。 - 选择。 按键的顺序检查
[subscribe.routes]的每一项。键必须是default或某个路由组名,否则报[subscribe.routes] names "<key>", which is not a route group in <file>。值必须是已占用的 tag(配置中的出站、负载均衡器或节点)、direct或blackhole,否则报[subscribe.routes] "<key>" = "<tag>": no node, outbound or balancer has that name。代码注释给出了原因:两者中任何一处的笔误都必须让配置失败,而不是让流量留在用户没有选择的节点上。 - 规则与默认目标。 配置中有
[[route.rule]]条目时,一条警告说明它们被忽略。它们在第 5 阶段被整体替换,即使文件中没有路由组也一样。[route]的geoip和geosite路径仍然生效。[subscribe.routes] default存在时,覆盖cfg.route.default。于是inherit = "default"所指的默认 tag 为cfg.route.default,没有时为合并后列表中的第一个出站:配置的第一个出站,配置没有出站时则是第一个节点。两者都没有时,合并失败,报no node or outbound to send unmatched traffic to。之后 lowering 为路由表的默认目标采用同样的回退。 - 路由组变成规则。 按文件顺序,对每个路由组由
resolve_group找到它的 tag,由push_group_rules追加它的规则。新的列表替换cfg.route.rules。 - 内置出站。 规则或默认目标指向
direct和blackhole时,ensure_builtins(cfg, true)创建它们,并拒绝名称相同但协议不符的出站。 - DNS。 文件有
[dns]时,把它克隆到cfg.subscribe_dns。如果配置自己的[dns]不是全默认的表,一条警告说明它被忽略。cfg.dns本身保持不动;设置了subscribe_dns时,lowering 不读取它。
一个合并示例
Section titled “一个合并示例”与上面的文件配套的配置:
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080
[route]geoip = "/etc/etemenanki/geoip.dat"geosite = "/etc/etemenanki/geosite.dat"
[subscribe]path = "/etc/etemenanki/sub.toml"
[subscribe.routes]default = "vless-ws"Proxy = "vmess-grpc"这个配置自己没有出站,所以合并后的出站依次是 vless-ws、vmess-grpc、trojan-tls、ss-2022、hy2、wg、direct、blackhole。最后两个在第 6 阶段创建,direct 在 blackhole 之前。合并后的路由:
| # | 来自路由组 | 匹配条件 | 出站 | 原因 |
|---|---|---|---|---|
| 1 | Ads |
geosite = ["category-ads-all"] |
blackhole |
没有选择;继承 blackhole |
| 2 | Streaming |
domain_suffix = ["video.example.com"]、geosite = ["netflix"] |
vmess-grpc |
没有选择;继承 Proxy,而 Proxy 的选择是 vmess-grpc |
| 3 | Local |
cidr = ["192.0.2.0/24"]、geoip = ["private"] |
direct |
没有选择;继承 direct |
| 4 | Proxy |
domain_full = ["api.example.com"]、port = ["443", "8000-9000"] |
vmess-grpc |
已选择 |
| 5 | Proxy |
network = "udp" |
vmess-grpc |
该路由组的 network 匹配条件,单独成为一条规则 |
| 默认 | vless-ws |
[subscribe.routes] default |
节点变成出站
Section titled “节点变成出站”fn node_outbound(node: &ProxyServer, server_family: Option<&'static str>) -> OutboundConfig;fn address_family(policy: ProxyServerIpv6DnsPolicy) -> &'static str;fn stream_config(stream: &Stream) -> StreamConfig;fn tls_config(tls: &Tls) -> TlsConfig;fn credentials(set: &mut impl FnMut(&str, toml::Value), user: Option<&str>, pass: Option<&str>);fn shadowsocks_method(method: ShadowsocksMethod) -> &'static str;node_outbound 把节点写成运维者本会手写的那个 [[outbound]] 表:tag 是节点的 name,protocol 是变体名,协议键以 toml::Value 的形式放进不透明的 settings 表。lowering 随后用与任何出站相同的 settings 结构体读取这个表,所以节点受到的检查与手写的出站完全相同。
protocol |
server、port |
写入的 settings 键 |
stream |
|---|---|---|---|
vless |
节点的 | id |
stream_config |
vmess |
节点的 | id,以及总会写入的 security:auto、aes-128-gcm 或 chacha20-poly1305 |
stream_config |
trojan |
节点的 | password |
stream_config |
shadowsocks |
节点的 | method(来自 shadowsocks_method 的规范名)、password |
stream_config |
socks、http |
节点的 | user 和 pass,各自只在存在时写入(credentials) |
stream_config |
hysteria2 |
节点的 | password;设置了 server_name 时写入它;allow_insecure = true 只在为 true 时写入;设置了 obfs 时写入 obfs = "salamander" 和 obfs_password |
默认值(无) |
wireguard |
不设置:对端在 endpoint 中 |
private_key、peer_public_key,设置了 preshared_key 时写入它,endpoint = "<server>:<port>",address 写成字符串数组,设置了 mtu、keepalive 和 reserved 时写入它们,文件有 [dns] 时写入 endpoint_address_family |
默认值(无) |
除以下几项外,每个值都是 toml::Value::String:Hysteria 2 的 allow_insecure 是 Boolean;WireGuard 的 mtu 是 Integer(mtu as i64),keepalive 是 Integer,reserved 是由三个 Integer 组成的 Array。
Hysteria 2 的 obfs 表被写成 app 的两个键 obfs 和 obfs_password,由 app/src/lower.rs → lower_obfs 读取。
WireGuard 的 endpoint 是一个 server:port 字符串。IPv6 字面量直接写入,不加方括号,例如 server = "2001:db8::1" 得到 endpoint = "2001:db8::1:51820":app/src/lower.rs → lower_wireguard 在最后一个冒号处切分 endpoint。
stream_config
Section titled “stream_config”节点的 stream |
app 的 [outbound.stream] |
|---|---|
不写,或 { network = "tcp" } |
StreamConfig::default():没有 network,即 TCP |
{ network = "tcp", tls = … } |
network = "tls",tls 来自 tls_config |
{ network = "ws", … } |
network = "ws",有 tls 时 security = "tls",ws.path 和 ws.host 照搬,tls 来自 tls_config 或取默认值 |
{ network = "grpc", … } |
network = "grpc",有 tls 时 security = "tls",grpc.service_name 和 grpc.authority 照搬,tls 来自 tls_config 或取默认值 |
tls_config 复制 server_name 和 allow_insecure,不设置 ca_file、cert_file 和 key_file。随后 app/src/transport.rs → resolve_stream 像校验任何出站的 stream 一样校验它,app/src/lower.rs → outbound_transport 依次用 tls.server_name、server 填入 WebSocket 的 Host、gRPC authority 和 TLS 服务器名。见 etemenanki-app:从 TOML 到 spec。
由 connect_ipv6 得出的地址族
Section titled “由 connect_ipv6 得出的地址族”connect_ipv6 |
写入的 address_family |
|---|---|
required |
ipv6_only |
preferred |
prefer_ipv6 |
tolerated |
prefer_ipv4 |
forbidden |
ipv4_only |
对 WireGuard 以外的每个节点,这个值写进出站的 address_family,lowering 再把它放进代理出站的传输层(OutboundTransportSpec::address_family)或 Hysteria2OutboundSpec::address_family,也就是访问节点服务器所用的地址族。WireGuard 出站自己的 address_family 决定的是在隧道内访问什么,所以对 WireGuard 节点,这个值改放到 settings.endpoint_address_family,address_family 保持不设置。测试 connect_ipv6_decides_how_every_node_server_is_reached 固定了这两种情形。
文件中没有 [dns] 时,什么都不写,lowering 使用 auto。配置自己的出站以及内置的 direct 和 blackhole 从不从文件获得地址族。
lowering 补上的值
Section titled “lowering 补上的值”| 项 | 值 | 位置 |
|---|---|---|
WebSocket path |
/ |
app/src/transport.rs → resolve_stream |
WebSocket Host、gRPC authority、TLS 服务器名 |
tls.server_name,否则 server |
app/src/lower.rs → outbound_transport |
| TLS 校验 | VerifyMode::System,设置了 allow_insecure 时为 Insecure |
app/src/lower.rs → verify_mode |
VMess security = "auto" |
AES-128-GCM | app/src/lower.rs → parse_security |
Hysteria 2 server_name |
节点的 server |
app/src/lower.rs → lower_hysteria2_outbound |
| Hysteria 2 并发流数 | 102,400 | protocols/src/hysteria/config.rs → DEFAULT_MAX_CONCURRENT_STREAMS |
WireGuard mtu |
1420 | app/src/lower.rs → lower_wireguard,配合 protocols/src/wireguard/config.rs → DEFAULT_MTU |
| 未设置的地址族 | auto |
app/src/lower.rs → parse_family |
路由组变成规则
Section titled “路由组变成规则”resolve_group
Section titled “resolve_group”pub(crate) fn resolve_group<'a>( group: &'a RouteGroup, groups: &HashMap<&str, &'a RouteGroup>, picks: &BTreeMap<String, CompactString>, default_tag: &CompactString, resolved: &mut HashMap<&'a str, CompactString>,) -> io::Result<CompactString>;resolve_group 返回一个路由组的流量所去往的 tag:用户的选择,否则是该组继承的目标,沿 { group = … } 链一路追下去。它从该路由组出发遍历,把经过的名称记在 chain 中:
flowchart TB
at["at = 该路由组"]
known{"at 已经解析过?"}
picked{"at 有选择?"}
push["把 at 压入 chain"]
inherit{"at.inherit"}
exists{"所指的路由组存在?"}
cycle{"所指的路由组已在 chain 中?"}
done["为 at 和 chain 记录 tag"]
missing["错误:不是路由组"]
cyc["错误:继承成环"]
at --> known
known -->|"是,取它的 tag"| done
known -->|"否"| picked
picked -->|"是,取该选择"| done
picked -->|"否"| push --> inherit
inherit -->|"default, direct, blackhole"| done
inherit -->|"group"| exists
exists -->|"否"| missing
exists -->|"是"| cycle
cycle -->|"是"| cyc
cycle -->|"否:at = 该路由组"| known
default解析为default_tag,direct解析为DIRECT,blackhole解析为BLACKHOLE。- 继承一个不存在的路由组时失败,报
route group "<name>" inherits "<other>", which is not a route group。 - 继承回到链中时失败,报
route groups inherit in a cycle: A -> B -> C -> A:先是用->连接的链,再接上它将要回到的那个路由组。继承自身的路由组给出A -> A。 - 遍历结束时,tag 被记录到
resolved中,对应遍历终止处的路由组以及链中的每个名称。apply让所有路由组共用一个resolved映射,所以后面的路由组一旦到达已解析的路由组,就在那里停下。
push_group_rules
Section titled “push_group_rules”fn push_group_rules(rules: &mut Vec<RuleConfig>, group: &RouteGroup, outbound: &CompactString);fn empty_rule(outbound: CompactString) -> RuleConfig;app 规则中的各个匹配条件之间是“或”的关系,这正是路由组列表的语义,所以一条规则就能承载全部匹配条件。除 network 外,每种匹配条件都追加到该规则中同种类的列表里。一个路由组有两项同种类的条目时,它们的值会合并到一个列表中。
network 是例外,因为一个 RuleConfig 只容纳一个 network 值。每个不同的 network 值按首次出现的顺序、去重后各自成为一条只含该 network 的规则,放在路由组的主规则之后,发往同一个出站。这与一条同时匹配两种网络的规则等价。测试 network_matchers_become_rules_of_their_own 固定了这种排布:一个含 { port = ["443"] } 和 { network = ["tcp", "udp"] } 的路由组得到三条规则,先是端口规则,然后是 tcp,最后是 udp。
只有至少含一个非 network 匹配条件时,主规则才会被追加。所以 matches 列表为空的路由组根本不添加规则。它仍然存在,可以被选择和继承,也仍然会被解析,所以它的 inherit 与其他路由组一样受检查。
从规则到路由表
Section titled “从规则到路由表”app/src/lower.rs → lower_route 把合并后的每条规则变成一个 RouteRuleSpec,与处理配置自己的规则相同:
| 匹配条件 | 由谁降为 spec | 检查 |
|---|---|---|
domain_suffix、domain_keyword、domain_full |
to_ascii_lowercase,每个值一次 |
无 |
domain_regex |
environment/src/routing.rs → parse_domain_regexes:一条规则的所有模式一起编译成一个匹配器(DomainRegexSet::new) |
每个模式都必须能编译,整个集合也必须能编译 |
cidr |
app/src/lower.rs → parse_cidr,解析为 IpCidr |
合法的地址和前缀长度 |
port |
environment/src/routing.rs → parse_port_match |
一个数字或范围;拒绝下界大于上界的范围 |
network |
app/src/lower.rs → parse_network |
这里总是 tcp 或 udp,因为格式层已经检查过 |
geosite、geoip |
只记下名称,不加载 | supervisor 构建路由时,从 [route] 的路径加载规则用到的集合 |
在一条规则内部,匹配条件按种类排列,而不是按文件中的顺序。这不会改变任何结果:在 environment/src/routing.rs 中,一条规则只要有任一匹配条件匹配就会被选中,并且第一条匹配的规则生效。所以路由组的规则保持文件中路由组的顺序,路由就是按路由组从上到下取首个匹配。见路由模型,以及介绍 plane(数据平面)的 plane:为每个流选路。
其他遍历路由组的代码
Section titled “其他遍历路由组的代码”app/src/routes.rs 构建 REST API 和 FFI 显示的路由视图。Snapshot::of 在加载路径之外用 subscribe::parse 再次解析订阅文件的字节,并把错误映射为 ControlError::Invalid。它不做合并:视图描述的是文件的原样内容。它复用本模块的 resolve_group、DEFAULT_ROUTE、DIRECT 和 BLACKHOLE,所以视图与合并结果一致:
- 目标依次是配置自己的出站和负载均衡器,然后是作为
TargetKind::Node的节点,最后是direct和blackhole(除非已列出的某个目标用了这个名称); - 默认目标是
[subscribe.routes] default的选择,否则是[route] default,否则是配置的第一个出站,再否则是第一个节点,这正是合并的回退顺序; - 路由组显示的目标是
resolve_group给出的 tag,也就是合并会把它发往的 tag。
修改选择的方式是用 toml_edit 编辑配置文件中的 [subscribe.routes]。订阅文件本身从不被写入。见 REST API(etemenanki-webclient)和移动端库(etemenanki-ffi)。
内置的 direct 与 blackhole
Section titled “内置的 direct 与 blackhole”pub(crate) fn ensure_builtins(cfg: &mut Config, strict: bool) -> io::Result<()>;fn ensure_builtin(cfg: &mut Config, tag: &str, protocol: &str, strict: bool) -> io::Result<()>;ensure_builtins 按需让 direct 和 blackhole 存在,这样即使配置没有定义它们,路由组也能继承它们,选择也能指向它们。它先对 (DIRECT, "freedom")、再对 (BLACKHOLE, "blackhole") 检查 cfg.route.default 或任何规则的 outbound 是否指向该 tag。只有指向时,才对它运行 ensure_builtin:
| 谁带有这个 tag | 宽松模式(strict = false,没有订阅文件) |
严格模式(strict = true,来自 apply) |
|---|---|---|
| 没有 | 追加一个新出站:该 tag,protocol 为 freedom 或 blackhole,没有服务器、端口、stream 或地址族,settings 为空表 |
相同 |
配置中协议相同的出站(freedom 或其别名 direct;blackhole 或其别名 block) |
原样保留 | 原样保留 |
| 配置中协议不同的出站 | 保留:tag 和指向它的路由都是配置自己写的 | 拒绝:"direct" is routed to, but the outbound tagged "direct" is "socks", not "freedom" |
| 负载均衡器 | 保留 | 拒绝:"direct" is routed to, but it names a balancer, not a "freedom" outbound |
严格模式之所以存在,是因为订阅文件的路由是服务商写的:本该直连的流量不能悄悄从一个恰好名为 direct 的代理出去。严格模式检查合并之后指向该 tag 的每一条路由,包括默认目标,无论它是由配置还是由 [subscribe.routes] 设置的。那时节点也已经是出站,而没有哪种节点协议是 freedom 或 blackhole,所以只要有路由指向 direct,名为 direct 的节点就会被拒绝,blackhole 同理。
没有订阅文件时,宽松模式仍会创建内置出站。direct_and_blackhole_exist_when_a_route_names_them 的文档注释给出了原因:这样 REST API 就能把两者作为目标提供,同时仍然只编辑 [route] default。
带 [dns] 的订阅文件完全取代配置的 [dns]。apply 把这个表存进 Config::subscribe_dns,app/src/lower.rs → lower_dns 首先检查这个字段:
fn lower_dns(cfg: &Config) -> io::Result<DnsSpec> { let Some(sub) = &cfg.subscribe_dns else { return lower_single_dns(&cfg.dns).map(DnsSpec::Single); }; let backends = |urls: &[String]| -> io::Result<Vec<Backend>> { urls.iter().map(|url| Backend::from_url(url)).collect() }; Ok(DnsSpec::Split { pre_proxy: backends(&sub.pre_proxy)?, through_proxy: backends(&sub.through_proxy)?, use_hosts: sub.use_hosts, resolve_ipv6: sub.resolve_ipv6, })}[dns] 键 |
在 spec 中 |
|---|---|
pre_proxy |
DnsSpec::Split::pre_proxy,每个 URL 经过 Backend::from_url |
through_proxy |
DnsSpec::Split::through_proxy,处理方式相同 |
use_hosts |
use_hosts |
connect_ipv6 |
不在 DnsSpec 中。它在合并时已变成每个节点的地址族。 |
resolve_ipv6 |
resolve_ipv6 |
订阅文件的 [dns] 是 etemenanki-app 产生 DnsSpec::Split 的唯一途径。lowering 保留 through_proxy 中的 system(a_subscribe_file_splits_dns 固定了这一点):拒绝它是 supervisor 的事。supervisor/src/build/dns.rs → build_dns 用 protocols/src/dns/mod.rs → Resolver::with_upstreams 构建解析器,后者拒绝位于拨号器之后的 system,也拒绝在没有 bootstrap 解析器可供查询时以名称书写的服务器。pre_proxy 中的 system 是被接受的。supervisor 从 Split spec 构建出:
| 构建出的 | 服务器 | 访问方式 | 用途 |
|---|---|---|---|
Dns::servers(直连解析器) |
pre_proxy;允许 system,拒绝名称 |
从本机访问:没有拨号器,没有 bootstrap | 节点的服务器名,以及 through_proxy 服务器的名称 |
Dns::destinations(经代理的解析器) |
through_proxy;拒绝 system,名称由直连解析器查询 |
只由路由表决定路由(DNS 服务不拦截这些查询),所以由路由组决定哪个目标承载它们 | freedom 出站(包括内置的 direct)和 WireGuard 隧道所拨号的目的地,以及 DNS 服务的应答。through_proxy 为空时,它就是直连解析器。 |
Dns::service |
— | — | 应用程序发往 53 端口的流,从 destinations 应答,只有在 resolve_ipv6 下才有 AAAA 应答 |
所以在订阅文件有 [dns] 时,direct 流量所拨号的名称也由 through_proxy 服务器解析。设置了 use_hosts 时,两个解析器都先用系统 hosts 文件应答。protocols/src/dns/hosts.rs → Hosts::system 在构建 DNS 时读取这个文件:文件不存在视为空表,其他任何读取错误都会让构建失败(building dns failed: …)。详见名称解析与 DNS 服务和 DNS 解析器。
文件中没有 [dns] 时,配置自己的 [dns] 照常降为 DnsSpec::Single。
重载、API 与 FFI
Section titled “重载、API 与 FFI”Sources把订阅文件的字节和配置的字节放在一起,所以只修改订阅文件也会改变重载读到的 sources。重载如何比较 sources 见 etemenanki-app:运行、重载与关停。- 每次到达合并的加载都会重新运行解析、合并和 lowering,再把 spec 交给 supervisor:启动(app 的或 FFI 的)、
--test和 REST API 对编辑的检查(两者都经由supervisor::check),以及 sources 发生变化的重载,无论是 app 自己的重载,还是 FFI 的reload或set_route。加载之间不保留任何合并状态,所以每次到达合并的加载都会记录apply的警告,--test也不例外。重载何时在合并之前就停下,见 app 重载页面。 - 文件监视器跟踪
[subscribe] path指定的订阅文件;见 etemenanki-app:运行、重载与关停。 - 节点变化时,一次应用(apply)保留什么由 supervisor 决定;见规划并应用变更。
- 直接拿到文件内容的前端程序调用
sources_given,它的[subscribe]不需要path。FFI 的Proxy::start和Proxy::reload就是这样做的。它的set_route编辑自己持有的配置字节,再与已有的订阅文件字节一起重载。见移动端库(etemenanki-ffi)。
| 不变量 | 机制 | 由谁固定 |
|---|---|---|
| 订阅文件中任何位置拼错或缺少的键都会让解析失败,节点内部也不例外 | 除 ProxyServer 外的每个结构体以及内部标记的 Stream 和 Hysteria2Obfs 都带 deny_unknown_fields;flatten 把未知的节点键交给会拒绝它们的协议结构体;其他枚举的未知变体本身就会失败;DnsConfig 没有默认值 |
subscribe/tests/unit/subscribe.rs → a_mistyped_or_missing_key_is_rejected(节点键、另一种协议的键、stream 键、TLS 键、未知协议、顶层表、DNS 键、缺少的 DNS 键、没有密码的 Salamander、扁平的 obfs_password、路由组键、inbound_tag 匹配条件) |
toml::to_string 写出的文件读回后与原值相等 |
写出时只跳过默认值 | a_written_subscription_reads_back_unchanged |
示例解析为预期的带类型值;没有 stream 即纯 TCP |
serde 形态;Stream::default |
the_example_parses_into_typed_nodes_groups_and_dns |
服务端一侧仍属于配置;节点跟在配置自己的出站之后;direct 和 blackhole 排在最后 |
apply 第 1 阶段在配置的出站之后追加;ensure_builtin 追加在末尾 |
app/tests/unit/subscribe.rs → the_file_supplies_outbounds_rules_and_dns_while_inbounds_stay |
[subscribe.routes] default 优先于 [route] default,配置自己的规则被移除 |
第 4 阶段 | the_file_supplies_outbounds_rules_and_dns_while_inbounds_stay |
| 路由组发往它的选择,否则发往它继承的目标,并沿路由组链追溯;规则保持文件中路由组的顺序 | resolve_group,按文件顺序遍历路由组 |
a_group_goes_to_its_pick_else_what_it_inherits |
| 每个 network 值在路由组的其他匹配条件之后单独成为一条规则 | push_group_rules |
network_matchers_become_rules_of_their_own |
选择了未知的路由组或 tag、节点与配置的出站冲突、继承成环、继承不存在的路由组、路由组名为 default,各自都会让配置失败 |
第 1 到第 3 阶段,resolve_group |
a_mistyped_pick_or_a_broken_file_fails_the_config |
发往 direct 的路由绝不会从名为 direct 的非 freedom 出站或负载均衡器离开 |
严格模式下的 ensure_builtin |
a_direct_tag_that_is_not_freedom_is_refused_when_routed_to(一个 SOCKS 出站);app/tests/unit/api.rs → an_edit_that_would_not_start_is_422_and_leaves_the_file_untouched(一个负载均衡器:该选择被以 422 拒绝,错误中包含 names a balancer) |
| 示例降为的每个节点都是 supervisor 能构建的出站,连同分离式 DNS 设置一起 | node_outbound 写入 lowering 读取的键 |
every_lowered_node_builds_and_the_dns_setup_with_it(经由 supervisor::check,使用去掉了路由组的示例,因为该测试没有 geodata) |
connect_ipv6 决定每个节点的服务器如何访问;WireGuard 以 endpoint_address_family 得到它 |
address_family、node_outbound |
connect_ipv6_decides_how_every_node_server_is_reached |
没有订阅文件时,指向 direct 或 blackhole 的路由仍会得到内置出站 |
effective 中的 ensure_builtins(cfg, false) |
app/tests/unit/config.rs → direct_and_blackhole_exist_when_a_route_names_them |
没有订阅文件时,配置自己名为 direct 的出站无论协议如何都保留这个 tag |
宽松模式 | a_plain_configs_own_outbound_keeps_its_builtin_tag |
从磁盘读取的 [subscribe] 需要 path |
sources_from |
a_subscribe_section_read_from_disk_needs_a_path |
| 直接传入内容时,配置和文件必须就是否有订阅文件达成一致 | sources_given |
given_sources_must_agree_on_a_subscribe_file;经由 FFI 的是 ffi/tests/proxy.rs → set_route_and_reload_switch_like_the_rest_api(订阅文件配上没有 [subscribe] 的配置得到 FfiError::Config,正在运行的内容不变) |
订阅文件的 [dns] 降为 Split,through_proxy 中的 system 被保留,留给 build_dns 拒绝 |
lower_dns |
app/tests/unit/lower.rs → a_subscribe_file_splits_dns |
路由视图与合并一致:节点是目标,direct 和 blackhole 被加入,每个路由组显示它解析到的 tag,默认目标回退到第一个出站 |
routes.rs 复用 resolve_group 和合并的常量 |
app/tests/unit/api.rs → lists_each_groups_pick_and_inherit_and_the_targets、without_a_subscribe_file_the_default_route_is_the_one_pick |
| 端到端地看,路由组的规则会为真实的流选路,切换它的选择后,该组的新流会发往新目标 | 合并、lowering、应用 | app/tests/integration/e2e_api.rs → a_switch_sends_new_flows_of_the_group_through_the_new_target |
失败路径与取消
Section titled “失败路径与取消”格式、合并、一致性检查和 lowering 的错误都是 kind 为 InvalidInput 的 io::Error,以 LoadError::Config 的形式到达 app。supervisor 的错误(校验、构建 DNS、构建路由)是 supervisor/src/build/apply.rs → ApplyError 值:违反规则的 spec 得到 Invalid(<resource>: <reason>),无法构造的 spec 得到 Build(building <resource> failed: <source>)。它们以 LoadError::Apply 的形式到达 app。app/src/instance.rs → From<LoadError> for ControlError 决定引发这次加载的 REST API 或 FFI 客户端得到什么:
LoadError |
ControlError |
|---|---|
kind 为 InvalidInput 或 InvalidData 的 Config:下文合并和 lowering 的错误、没有 path 的 [subscribe],以及无法解析的配置 |
Invalid |
其他任何 kind 的 Config,例如无法读取的订阅文件的 OS 错误 |
Failed |
带 ApplyError::Bind 或 ApplyError::Stopped 的 Apply |
Failed |
其他任何 Apply,例如 Salamander 密钥长度或 building dns failed: … |
Invalid |
所以订阅文件内容引起的每个错误都是 Invalid:是配置无效,而不是加载失败。REST API 以 422 应答,ffi/src/error.rs 把它变成 FfiError::Config。REST API 自己的读取(app/src/api.rs → read)在读取错误前加上 <config path>: ,把 InvalidInput 映射为 Invalid,其他 kind 映射为 Failed;见 REST API(etemenanki-webclient)。
这些错误都以 subscribe: 开头。文本是用固定版本的二进制在 --test 下捕获的。
| 条件 | subscribe: 之后的文本 |
|---|---|
| 文件不是 UTF-8 | Utf8Error 的文本,例如 invalid utf-8 sequence of 1 bytes from index 0 |
文件无法解析:语法错误、未知或缺少的键、未知的 protocol、类型错误或超出范围的值 |
TOML parse error at line <n>, column <m>,即解析器消息的第一行 |
| 节点名已是配置中某个出站的 tag、某个负载均衡器的 tag 或更早某个节点的名称 | node "<name>" is defined twice, or shares its name with a config outbound |
路由组名为 default |
a route group may not be named "default": [subscribe.routes] uses that key for the default node |
| 两个路由组同名 | route group "<name>" is defined twice |
[subscribe.routes] 的某个键既不是 default 也不是路由组 |
[subscribe.routes] names "<key>", which is not a route group in <file> |
[subscribe.routes] 的某个值不指向任何节点、出站、负载均衡器、direct 或 blackhole |
[subscribe.routes] "<key>" = "<tag>": no node, outbound or balancer has that name |
| 没有设置默认目标,并且根本没有出站 | no node or outbound to send unmatched traffic to |
| 继承遍历到达一个不存在的路由组名 | route group "<name>" inherits "<other>", which is not a route group |
| 继承遍历回到链中已有的路由组 | route groups inherit in a cycle: A -> B -> C -> A |
路由指向 direct 或 blackhole,而带这个 tag 的是另一种协议的出站 |
"direct" is routed to, but the outbound tagged "direct" is "socks", not "freedom",或 "blackhole" is routed to, but the outbound tagged "blackhole" is "freedom", not "blackhole" |
路由指向 direct 或 blackhole,而带这个 tag 的是负载均衡器 |
"direct" is routed to, but it names a balancer, not a "freedom" outbound |
<file> 是 SubscribeConfig::describe 的结果:按原样写出的 path,或者 the subscribe file。
合并之后由文件引起的错误
Section titled “合并之后由文件引起的错误”格式层以字符串保存的节点、路由组的值或 DNS URL,由 lowering 或 supervisor 检查。这些错误不带 subscribe: 前缀。出站错误会写出节点名,因为节点名就是出站的 tag;匹配条件的错误写出值,但不写路由组。以下文本用固定版本的二进制捕获:
| 位置 | 条件 | 文本 |
|---|---|---|
app/src/lower.rs → parse_uuid |
不是 UUID 的 VLESS 或 VMess id |
outbound <name>: invalid uuid "<id>": <uuid error>,例如 … invalid uuid "not-a-uuid": invalid character: found `n` at 0 |
app/src/lower.rs → lower_wireguard |
无法解析的 WireGuard 密钥 | outbound <name>: invalid wireguard private_key,peer_public_key 或 preshared_key 同理 |
app/src/lower.rs → lower_shadowsocks_outbound |
不是 base64 的 2022 PSK | outbound <name>: decode PSK: <base64 error>,例如 … decode PSK: Invalid input length: 5 |
app/src/lower.rs → lower_shadowsocks_outbound |
对其方法而言过短的 2022 PSK | 16 字节的密钥配 32 字节的方法时为 outbound <name>: shadowsocks-2022: PSK too short (16 < 32) |
supervisor/src/build/validate.rs(supervisor 校验) |
短于 4 字节的 Salamander 密码 | outbound <name>@v0: salamander obfs key must be at least 4 bytes |
protocols/src/dns/mod.rs → Backend::from_url |
使用其他 scheme 的 DNS URL | dns: server "ftp://192.0.2.53": unknown scheme "ftp" |
Backend::from_url |
没有 scheme 的 DNS 服务器 | dns: server "192.0.2.53": expected "system" or a udp://, tls:// or https:// url |
Backend::from_url |
带片段的 udp:// URL |
dns: server "udp://192.0.2.53#x": a udp server has no name to verify |
Backend::from_url |
片段为空的 tls:// URL |
dns: server "tls://192.0.2.53#": empty server name |
Backend::from_url |
没有方括号的 IPv6 主机 | dns: server "udp://2001:db8::53": has an invalid port |
Backend::from_url |
带 userinfo 或没有主机的 https:// authority |
dns: "https://user@198.51.100.53/dns-query" has no usable host |
Resolver::with_upstreams,来自 build_dns |
through_proxy 中有 system |
building dns failed: dns: the system resolver cannot be reached through a proxy |
Resolver::with_upstreams,来自 build_dns |
以名称书写的 pre_proxy 服务器 |
building dns failed: dns: server "dns.example.com" is a name, and nothing resolves it; write its address |
app/src/lower.rs → parse_cidr |
错误的 CIDR | invalid cidr "192.0.2.0/33": invalid length for network: Network length 33 is too long for Ipv4 (maximum: 32) |
environment/src/routing.rs → parse_port_match |
倒置的端口范围 | invalid port spec: "2000-1000" has a lower bound above its upper bound |
environment/src/routing.rs → DomainRegexSet::new |
无法编译的正则 | invalid domain regex "<pattern>": regex parse error: …,后接解析器跨多行的消息;每个模式单独都能编译、但整个集合不能时,为 domain regex set of <n> patterns: <error> |
environment/src/routing.rs → build_geo_data,来自 supervisor 的路由构建 |
使用了 geosite 匹配条件但没有 [route] geosite |
building route failed: a geosite matcher is used but no geosite file is configured |
有些 lowering 错误不可能由订阅文件引起,因为 node_outbound 总是写入格式正确的值。wireguard endpoint must be host:port 和 invalid wireguard endpoint port 不会出现,因为 endpoint 总是 <server>:<port>,端口是 u16。ws stream needs ws.host or server 和 grpc stream needs grpc.authority or server 不会出现,因为除 WireGuard 外每个节点都有 server。unknown vmess security 和 unknown shadowsocks method 不会出现,因为这两个枚举只写出 lowering 接受的名称。config defines no outbounds 也不会出现:apply 会先以 no node or outbound to send unmatched traffic to 失败。
app 如何输出这些错误
Section titled “app 如何输出这些错误”| 时机 | 日志行 | 级别,target |
|---|---|---|
--test |
configuration invalid: <error>,然后以失败状态退出 |
ERROR,etemenanki_app |
| 启动 | failed to start: <error> |
ERROR,etemenanki_app |
| 重载,被合并、lowering 或 supervisor 的校验或构建拒绝 | reload: <error>; keeping the running config |
ERROR,etemenanki_app::instance |
| 重载,订阅文件无法读取 | 重载失败,错误被记录 | ERROR,etemenanki_app::instance |
例如在 --test 下:configuration invalid: subscribe: route groups inherit in a cycle: A -> B -> C -> A,以及 configuration invalid: subscribe file missing.toml: No such file or directory (os error 2)。
apply 记录两条警告,级别都是 WARN,target 为 etemenanki_app::subscribe。两者都不会中止合并。
| 日志行 | 时机 |
|---|---|
[subscribe] is set, so the config's <n> [[route.rule]] entries are ignored; routing comes from the route groups in <file> |
配置中至少有一条 [[route.rule]] |
<file> has a [dns] section, so the config's [dns] is ignored |
文件有 [dns],并且配置的 [dns] 设置了任何键,哪怕是 backend = "system" |
格式 crate 和合并中没有其他日志。
这里没有可取消的东西。parse、apply、ensure_builtins 和各辅助函数都是作用于内存中值的同步函数。它们不 spawn 任务、不持有锁、不打开 channel,也不设置定时器。它们在调用 effective 的那次加载中运行;失败时正在运行的配置保持原样,因为在整个合并后的配置完成 lowering 之前,没有任何东西会到达 supervisor。
| 项 | 值 | 位置 |
|---|---|---|
DEFAULT_ROUTE |
"default" |
app/src/subscribe.rs。保留名:任何路由组都不能使用这个名称。 |
DIRECT |
"direct",创建时是 freedom 出站 |
app/src/subscribe.rs |
BLACKHOLE |
"blackhole",创建时是 blackhole 出站 |
app/src/subscribe.rs |
节点 port |
u16 |
ProxyServer::port |
WireGuard reserved |
恰好 3 个值,每个 0 到 255 | [u8; 3] |
WireGuard keepalive |
u16,单位为秒 |
Wireguard::keepalive |
WireGuard mtu |
不写时为 1420 | protocols/src/wireguard/config.rs → DEFAULT_MTU |
| Hysteria 2 并发流数 | 102,400,不能从文件设置 | protocols/src/hysteria/config.rs → DEFAULT_MAX_CONCURRENT_STREAMS |
| Salamander 密码 | 至少 4 字节 | supervisor/src/build/validate.rs → MIN_SALAMANDER_PSK |
每条规则的 network 值 |
一个,因此每个 network 值一条规则 | RuleConfig::network |
| DNS 默认端口 | 53(udp)、853(tls)、443(https) |
Backend::from_url |
| 层 | 文件 | 覆盖内容 |
|---|---|---|
| 格式 | subscribe/tests/unit/subscribe.rs |
the_example_parses_into_typed_nodes_groups_and_dns(节点顺序、带空 tls 的 gRPC VMess stream、一种 2022 方法、没有 stream 的节点即纯 TCP、WireGuard 地址和 reserved、DNS 策略、每种 inherit 写法、一个 domain_suffix 和一个 network 匹配条件)、a_written_subscription_reads_back_unchanged、a_mistyped_or_missing_key_is_rejected |
| 合并 | app/tests/unit/subscribe.rs |
the_file_supplies_outbounds_rules_and_dns_while_inbounds_stay、a_group_goes_to_its_pick_else_what_it_inherits、network_matchers_become_rules_of_their_own、a_mistyped_pick_or_a_broken_file_fails_the_config、a_direct_tag_that_is_not_freedom_is_refused_when_routed_to、every_lowered_node_builds_and_the_dns_setup_with_it、connect_ipv6_decides_how_every_node_server_is_reached。辅助函数 merged 对在内存中构建的 Sources 运行 effective,所以不读取任何文件。 |
| Sources 与内置出站 | app/tests/unit/config.rs |
direct_and_blackhole_exist_when_a_route_names_them、a_plain_configs_own_outbound_keeps_its_builtin_tag、a_subscribe_section_read_from_disk_needs_a_path、given_sources_must_agree_on_a_subscribe_file |
| DNS lowering | app/tests/unit/lower.rs |
a_subscribe_file_splits_dns |
| 路由视图与 API | app/tests/unit/api.rs |
lists_each_groups_pick_and_inherit_and_the_targets(节点作为 node 目标排在配置自己的出站之后、加入 direct 和 blackhole、路由组自己的选择优先于它的 inherit、没有选择的路由组显示默认目标)、without_a_subscribe_file_the_default_route_is_the_one_pick(默认目标回退到第一个出站;选择 direct 不会为它写入出站)、an_edit_that_would_not_start_is_422_and_leaves_the_file_untouched(配置中有名为 direct 的负载均衡器且有订阅文件:为某个路由组选择 direct 得到 422,错误中含 names a balancer) |
| FFI | ffi/tests/proxy.rs |
set_route_and_reload_switch_like_the_rest_api:用 set_route 切换路由组的选择后,该组的流发往新目标;重载正在运行的同一批文件时报告 unchanged;订阅文件配上没有 [subscribe] 的配置得到 FfiError::Config,选择保持原样 |
| 端到端 | app/tests/integration/e2e_api.rs |
a_switch_sends_new_flows_of_the_group_through_the_new_target:启动时,一个继承 blackhole 的路由组丢弃发往 127.0.0.0/8 的流。通过 PUT /v1/routes/Local 把选择切换到一个 SOCKS 节点后,这些流经过它;测试随后终止该节点,检查路由组被切断,以此证明流确实经过了节点而不是直连。切换到 direct 后它们直连,GET /v1/routes 显示该选择,配置文件中也记录了它。 |
没有测试覆盖 no node or outbound to send unmatched traffic to 错误、两个同名路由组、严格模式对名为 blackhole 但协议不符的出站的拒绝,或者两条警告中的任何一条。修改这些路径时需要在 app/tests/unit/subscribe.rs 中补充测试。测试套件的组织方式见测试。