跳转到内容

订阅文件

源码文件:31 个 · 核对版本 Etemenanki 555b7df
  • Etemenanki/subscribe/Cargo.toml
  • Etemenanki/subscribe/src/lib.rs
  • Etemenanki/subscribe/src/proxy_servers.rs
  • Etemenanki/subscribe/src/route.rs
  • Etemenanki/subscribe/src/dns.rs
  • Etemenanki/subscribe/example-subscribe.toml
  • Etemenanki/subscribe/tests/unit/subscribe.rs
  • Etemenanki/app/src/subscribe.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/src/routes.rs
  • Etemenanki/app/src/api.rs
  • Etemenanki/ffi/src/proxy.rs
  • Etemenanki/ffi/src/error.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/dns/hosts.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/supervisor/src/build/dns.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/app/tests/unit/subscribe.rs
  • Etemenanki/app/tests/unit/config.rs
  • Etemenanki/app/tests/unit/lower.rs
  • Etemenanki/app/tests/unit/api.rs
  • Etemenanki/app/tests/integration/e2e_api.rs
  • Etemenanki/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。

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。

下面这个文件用到了全部三个部分,除 SOCKS 和 HTTP 外每种协议各有一个节点,所有值都是占位值:

sub.toml
[[proxy_server]]
name = "vless-ws"
server = "proxy.example.com"
port = 443
protocol = "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 = 443
protocol = "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 = 443
protocol = "trojan"
password = "replace-with-a-long-random-password"
stream = { network = "tcp", tls = {} }
[[proxy_server]]
name = "ss-2022"
server = "203.0.113.7"
port = 8388
protocol = "shadowsocks"
method = "2022-blake3-aes-128-gcm"
password = "AAAAAAAAAAAAAAAAAAAAAA=="
[[proxy_server]]
name = "hy2"
server = "proxy.example.com"
port = 8443
protocol = "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 = 51820
protocol = "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 = true
connect_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/src/lib.rs
#[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" 背后都是它。它让写出的文件省略默认值,按代码注释的说法,这样文件就“和手写的一样短”。
subscribe/src/proxy_servers.rs
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 属性:

subscribe/src/proxy_servers.rs
#[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 键。

subscribe/src/proxy_servers.rs
#[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,证书都用系统根证书校验。

subscribe/src/proxy_servers.rs
#[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)。

subscribe/src/dns.rs
#[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”
subscribe/src/route.rs
#[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" Direct direct,不经代理
    inherit = "blackhole" BlackHole blackhole:连接被静默丢弃
    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 代码(见下文)。

规则 位置 效果
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] 表头。

没有包含 原因 节点得到的值
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 匹配条件 它们取决于客户端自己的设置 —
app/src/config.rs
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 { 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。

app/src/config.rs
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 解析并合并它。没有时,它仍会创建路由指向的内置出站,不过用的是宽松模式(见下文)。

app/src/subscribe.rs
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,其中没有任何东西标记某个出站是节点、某条规则来自路由组。

app/src/subscribe.rs
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
  1. 节点。 已占用 tag 的集合先装入配置中每个出站的 tag 和每个负载均衡器的 tag。每个节点的 name 按文件顺序插入。名称已在集合中时,合并失败,报 node "<name>" is defined twice, or shares its name with a config outbound,与负载均衡器冲突也报这一条。否则 node_outbound 把节点变成一个 OutboundConfig,追加到配置自己的出站之后。文件有 [dns] 时,它的 connect_ipv6 只映射一次为地址族,传给每个节点。节点排在最前,是因为之后的每项检查都要对照它们解析名称。
  2. 路由组。 名为 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,供继承遍历使用。
  3. 选择。 按键的顺序检查 [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。代码注释给出了原因:两者中任何一处的笔误都必须让配置失败,而不是让流量留在用户没有选择的节点上。
  4. 规则与默认目标。 配置中有 [[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 为路由表的默认目标采用同样的回退。
  5. 路由组变成规则。 按文件顺序,对每个路由组由 resolve_group 找到它的 tag,由 push_group_rules 追加它的规则。新的列表替换 cfg.route.rules。
  6. 内置出站。 规则或默认目标指向 direct 和 blackhole 时,ensure_builtins(cfg, true) 创建它们,并拒绝名称相同但协议不符的出站。
  7. DNS。 文件有 [dns] 时,把它克隆到 cfg.subscribe_dns。如果配置自己的 [dns] 不是全默认的表,一条警告说明它被忽略。cfg.dns 本身保持不动;设置了 subscribe_dns 时,lowering 不读取它。

与上面的文件配套的配置:

config.toml
[[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
app/src/subscribe.rs
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 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 写入的 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 从不从文件获得地址族。

项 值 位置
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
app/src/subscribe.rs
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 映射,所以后面的路由组一旦到达已解析的路由组,就在那里停下。
app/src/subscribe.rs
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 与其他路由组一样受检查。

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:为每个流选路。

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)。

app/src/subscribe.rs
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 首先检查这个字段:

app/src/lower.rs
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。

  • 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

格式、合并、一致性检查和 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。

格式层以字符串保存的节点、路由组的值或 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 失败。

时机 日志行 级别,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 中补充测试。测试套件的组织方式见测试。