跳转到内容

etemenanki-app:从 TOML 到 spec

源码文件:41 个 · 核对版本 Etemenanki 555b7df
  • Etemenanki/app/src/lib.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/api.rs
  • Etemenanki/app/src/subscribe.rs
  • Etemenanki/supervisor/src/supervisor.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/entity/user.rs
  • Etemenanki/supervisor/src/topology/spec_plan/mod.rs
  • Etemenanki/supervisor/src/topology/spec_plan/inbound.rs
  • Etemenanki/supervisor/src/topology/spec_plan/outbound.rs
  • Etemenanki/supervisor/src/topology/spec_plan/transport.rs
  • Etemenanki/supervisor/src/topology/spec_plan/route.rs
  • Etemenanki/supervisor/src/topology/spec_plan/dns.rs
  • Etemenanki/supervisor/src/topology/inbound/mod.rs
  • Etemenanki/supervisor/src/topology/balancer.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/protocols/src/ss_2022/crypto.rs
  • Etemenanki/protocols/src/ss_legacy/aead.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/protocols/src/tun/config.rs
  • Etemenanki/protocols/src/tun/device.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/hysteria/server/config.rs
  • Etemenanki/protocols/src/hysteria/server/authenticator.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/webclient/src/listen.rs
  • Etemenanki/ffi/src/client.rs
  • Etemenanki/app/tests/unit/config.rs
  • Etemenanki/app/tests/unit/lower.rs
  • Etemenanki/app/tests/unit/transport.rs
  • Etemenanki/app/tests/unit/subscribe.rs
  • Etemenanki/app/tests/unit/api.rs
  • Etemenanki/app/tests/integration/e2e_dns.rs
  • Etemenanki/app/tests/integration/e2e_balancer.rs

etemenanki-app 是 supervisor(监管器)的 TOML 前端程序。它把一个配置文件,以及配置可能指定的订阅文件,变成一个 Spec<UserKey>,也就是由 etemenanki_supervisor 校验、构建并运行的 spec(期望状态)。这条路径分两个阶段:app/src/config.rs 读取字节、把它们解析为普通结构体并合并订阅文件;app/src/lower.rs → lower 再把合并后的 Config 降为 spec,这一步称为 lowering(降为 spec)。除了由 main.rs 自己绑定的 REST API 监听器,app 中没有任何代码构建监听器、出站或路由器:构建是 supervisor 的职责。

本页沿着这条路径从字节走到 spec:schema 中每个段的结构体、lowering 的每一步及其所做的检查、入站和出站共用的 stream 规则,以及 --test 如何使用这个结果。给 app 添加配置键、协议或检查之前,请先读本页。supervisor 负责的规则见校验与应用错误,有类型的 spec 见 spec:期望状态,订阅文件的合并见订阅文件,文件监视器与重载见运行与重载。

配置与 lowering 代码:

  • 严格解析配置,拼错的键是错误,而不是默认值;
  • 读取配置指定的订阅文件,并把它合并进配置;
  • 读取所选 stream shape 和协议用到的证书、私钥和 CA 文件,并把它们的字节传入 spec;
  • 应用 schema 承诺的默认值,例如缺少 listen 时使用回环地址,缺少 strategy 时使用 failover;
  • 以配置中写明的名字(UserKey)命名每个用户,从不以凭据命名;
  • 补齐出站传输层中的每一处空缺(WebSocket host、gRPC authority、TLS server name),使 spec 中不留空缺;
  • 拒绝无法解析的字符串,以及 spec 无法表达的键组合。

它不负责:

  • 有类型的各部分如何组合在一起的所有规则:重复的 tag、引用不存在的 tag、负载均衡器成员、两个用户出示同一凭据、凭据长度、至少为 1 的上限、取值范围。这些由 supervisor 在应用 spec 时检查;
  • 解析证书、私钥和 CA 证书包,以及加载 geodata:两者都由 supervisor 的构建代码完成;
  • 绑定、创建设备以及任何网络 I/O。

app/src/lower.rs 在模块注释中说明了这一分工:app 负责只有 TOML 前端程序才知道的事,supervisor 负责有类型的各部分如何组合的一切。「在这里也检查一遍的规则会成为一份逐渐走样的副本,只在这里检查的规则则是其他每个前端程序都缺失的规则。」所以 lower 如实降为 spec,而不替人补救:指向不存在出站的规则可以正常降为 spec,由 supervisor 拒绝它。

归属方 拒绝 用 --test 捕获的文本
etemenanki-app,解析时 未知的键和表名、类型错误、缺少必填字段、不是 UTF-8 的字节 TOML parse error at line 1, column 1(第一行)
etemenanki-app,lowering 时 无法解析的字符串(UUID、加密方法名、CIDR、端口、密钥、地址族、network、security 值、策略、DNS 后端)、无法读取的文件、缺少用作 key 的名字的用户、同名的两个用户,以及没有意义的键组合 inbound in: users[0] has no email; a user of this inbound is named by its email
supervisor,在 validate 中 tag、引用、负载均衡器成员、用户集与凭据、范围与上限、平台支持 route references unknown outbound missing
supervisor,在构建代码中 无法解析的证书、私钥和 CA 证书包,无法加载的 geodata building inbound in failed: no certificate in PEM bundle

app/tests/unit/lower.rs → semantic_problems_are_left_to_the_supervisor 从 app 一侧固定了这一分工。它对一个专门违反 supervisor 规则的配置做 lowering,其中包括:重复的入站 tag、1000 的 TUN MTU、同时设置了密码和用户的 Hysteria 2 入站、1 秒的 UDP 空闲超时、0 个 circuit、1 字节的 salamander 密钥、两个用户共用的 Trojan 密码、只有 IPv4 地址却设为 ipv6_only 地址族的 WireGuard、密码为空且流数为 0 的 Hysteria 2 客户端,以及指向不存在出站的负载均衡器和规则。它断言 lowering 接受这个配置,然后在 spec 中核对其中若干值:规则指向的不存在的出站、1000 的 MTU、与用户集并存的 Hysteria 2 密码、超时和 0 个 circuit、WireGuard 地址族、为 0 的流数上限、负载均衡器的成员,以及共用一个密码的两个用户。这些规则、它们的顺序和文本见校验与应用错误。

etemenanki-app 既是库,也是二进制程序。app/src/lib.rs 声明库的模块,app/src/main.rs 是构建在库之上的一层很薄的二进制程序。app/Cargo.toml 把版本设为 2.1.1,etemenanki-app --version 打印的就是它。

模块 内容 介绍页面
config schema、parse_bytes、Sources 和 effective 本页
lower lower、UserKey,以及每个协议的 lowering 本页
transport tls_layer、resolve_stream、reject_stream_settings 本页
instance build、Built、Lowering、Read、check、check_bytes、LoadError;Core、Instance、Reload 前七项见本页;其余见运行与重载
subscribe parse、apply、ensure_builtins 订阅文件
api、routes api::options、ConfigFile、路由视图与编辑 REST API

FFI crate 依赖这个库,复用 config 和 lower,再配上自己的 lowering(ffi/src/client.rs → lowering),见 FFI。

每个调用方都以相同的顺序运行同样的三步:读取源、config::effective,然后是一次 lowering。它们的区别在于字节从哪里来,以及 spec 交给谁。

调用方 读取 lowering 使用 spec 交给
--test(main.rs → instance::check) std::fs::read(path),然后 config::sources_from instance::build etemenanki_supervisor::check,一次试运行
启动(Instance::start → Core::start) config::read_sources(&path),作为一个 Read 交出 Arc::new(build),作为 Lowering SupervisorBuilder::start
重载(Instance::reload → Core::reload_with) config::read_sources(&path),放在一个返回 Read 的闭包中 启动时给定的 Lowering Supervisor::apply
REST API 编辑(api.rs → ConfigFile::set_route) 编辑后的字节,经过 instance::check_bytes instance::build etemenanki_supervisor::check,在写入文件之前
FFI config::sources_given(config, subscribe) FFI 的 lowering(ffi/src/client.rs):先 lower,再做它自己的改写 它自己的一个 Core

启动、重载和 API 这几条路径见运行与重载和 REST API。本页介绍它们共用的部分以及 --test 路径。

app/src/config.rs
#[derive(Deserialize, Clone, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub log: LogConfig,
#[serde(default)]
pub dns: DnsConfig,
#[serde(default, rename = "inbound")]
pub inbounds: Vec<InboundConfig>,
#[serde(default, rename = "outbound")]
pub outbounds: Vec<OutboundConfig>,
#[serde(default, rename = "balancer")]
pub balancers: Vec<BalancerConfig>,
#[serde(default)]
pub route: RouteConfig,
#[serde(default)]
pub subscribe: Option<SubscribeConfig>,
#[serde(default)]
pub api: Option<ApiConfig>,
/// 订阅文件的 `[dns]`,由 `subscribe::apply` 合并后填入。
#[serde(skip)]
pub subscribe_dns: Option<etemenanki_subscribe::dns::DnsConfig>,
}

schema 中的每个结构体都带有 #[serde(deny_unknown_fields)]。借助 rename,数组在 TOML 中使用单数名:[[inbound]]、[[outbound]]、[[balancer]]、[[route.rule]]。subscribe_dns 从不从文件读取,只有合并过程会设置它。Config 及其下除各协议 settings 之外的每个结构体都派生 Clone 和 PartialEq;Config、LogConfig、DnsConfig、StreamConfig 及其三个子表、RouteConfig 还派生 Default。subscribe::apply 把 cfg.dns 与 DnsConfig::default() 比较,以决定是否警告配置的 [dns] 被忽略。schema 中没有任何结构体派生 Debug,所以解析后的配置(连同其中的密码)无法被整体格式化输出。

Config 的各部分由哪些代码读取:

字段 读取方
log 只有 main.rs → init_tracing 读取它,用于 [log].level,并且只在 RUST_LOG 未设置或无法解析时读取。lower 从不读取它。
dns lower_dns,除非设置了 subscribe_dns
inbounds、outbounds、balancers、route lower
subscribe config::sources_from(路径)、config::sources_given(该段是否存在)、subscribe::apply(各项选择)、Instance::watched_files(路径;见运行与重载)和 app/src/routes.rs(REST API 展示和编辑的选择)。lower 从不读取它。
api api::options,由 instance::build 在 lower 之后调用
subscribe_dns lower_dns
app/src/config.rs
pub struct LogConfig {
pub level: Option<String>, // 一条 EnvFilter 指令;None → "info"
}
pub struct DnsConfig {
pub backend: Option<String>, // "system"(默认)、"udp"、"tls"、"https"
pub server: Option<String>, // IP:port;除 system 外的每个后端
pub server_name: Option<String>, // 仅 tls
pub url: Option<String>, // 仅 https
pub ca_file: Option<String>, // tls 和 https
}
pub struct SubscribeConfig {
pub path: Option<PathBuf>,
pub routes: BTreeMap<String, CompactString>, // [subscribe.routes]
}
pub struct ApiConfig {
pub listen: String, // 默认为 etemenanki_webclient::DEFAULT_LISTEN
pub secret: Option<String>,
}
  • LogConfig。 level 被交给 EnvFilter::new,所以它接受级别名(error、warn、info、debug、trace),或 RUST_LOG 接受的任何指令。有效的 RUST_LOG 优先于它;见运行与重载。
  • DnsConfig。 默认后端有意使用主机解析器:结构体的注释说明,只有它遵循 /etc/hosts、nsswitch.conf 和搜索域,若默认使用 DNS 客户端,名字的解析结果就会改变。除 system 外的每个后端都要求 server,https 也不例外,因为从 URL 中取地址就意味着要先解析解析器本身。
  • SubscribeConfig。 对 schema 而言 path 是可选的。从磁盘加载的配置必须设置它(read_sources 拒绝没有它的配置);直接拿到订阅文件内容的前端程序(例如 FFI)不读取任何路径。相对路径以工作目录为基准,配置中的所有路径都是如此。describe() 返回这个路径,没有路径时返回 the subscribe file;合并过程的消息用它来指明文件,例如 [subscribe.routes] names "<key>", which is not a route group in <file>。在 routes 中,键 default 为没有任何路由组匹配的流量选择节点;值可以是任意出站或负载均衡器的 tag,也可以是始终可用的 direct 和 blackhole。routes 还能选择什么,见订阅文件。
  • ApiConfig。 listen 默认为 DEFAULT_LISTEN = "127.0.0.1:9090"(webclient/src/listen.rs)。api::options 用 Listen::from_str 解析它:含有 / 的值是 Unix socket 路径,./api.sock 这样的相对路径也算;其他值都必须是 ip:port,所以 localhost:9090 这样的主机名会被拒绝。除非 listen 是回环地址或 Unix socket,否则 secret 必填。REST API 本身见 REST API。
app/src/config.rs
pub struct InboundConfig {
pub tag: CompactString,
pub protocol: String,
pub listen: Option<String>, // 一个 IP,或以 '/' 开头的路径
pub port: Option<u16>,
pub stream: StreamConfig,
pub address_family: Option<String>,
pub sniffing: bool, // 默认 true
pub settings: toml::Value, // 默认:空表
}
pub struct OutboundConfig {
pub tag: CompactString,
pub protocol: String,
pub server: Option<String>,
pub port: Option<u16>,
pub stream: StreamConfig,
pub address_family: Option<String>,
pub settings: toml::Value, // 默认:空表
}
pub struct StreamConfig {
pub network: Option<String>, // "tcp"(默认)、"tls"、"ws"、"grpc"
pub security: Option<String>, // "tls" 或 "none"
pub tls: TlsConfig,
pub ws: WsStreamConfig,
pub grpc: GrpcStreamConfig,
}
pub struct TlsConfig {
pub server_name: Option<String>,
pub allow_insecure: bool, // 默认 false
pub ca_file: Option<String>,
pub cert_file: Option<String>,
pub key_file: Option<String>,
}
pub struct WsStreamConfig { pub path: Option<String>, pub host: Option<String> }
pub struct GrpcStreamConfig { pub service_name: Option<String>, pub authority: Option<String> }

InboundConfig.listen 默认为回环地址,而不是所有网卡。该字段的注释给出了原因:过去,反序列化失败的 listen 键(拼写错误、嵌套错误)会退回 0.0.0.0,把本应只在本机使用的代理暴露到网络上。deny_unknown_fields 能抓住拼写错误,回环默认值兜住它抓不住的情况;想监听所有网卡的服务端要明确写出来。

InboundConfig.address_family 会被反序列化,但没有任何代码读取它。sniffing 被复制到 InboundSpec.sniff;哪些流会被检查、检查多久,见嗅探。

StreamConfig.security 用于在 ws 或 grpc network 之下叠加 TLS;该字段的注释称 network = "tls" 是为向后兼容而保留的 TLS over TCP network。两个键如何组合见 tls_layer。

app/src/config.rs
pub struct BalancerConfig {
pub tag: CompactString,
pub outbounds: Vec<CompactString>, // 必填;顺序即故障转移的优先级
pub strategy: Option<String>, // "failover"(默认)或 "round_robin"
pub probe_interval: Option<u64>, // 秒
pub probe_timeout: Option<u64>, // 秒
}
pub struct RouteConfig {
pub default: Option<CompactString>,
pub geoip: Option<PathBuf>,
pub geosite: Option<PathBuf>,
pub rules: Vec<RuleConfig>, // [[route.rule]]
}
pub struct RuleConfig {
pub outbound: CompactString, // 必填
pub domain_suffix: Vec<CompactString>,
pub domain_keyword: Vec<CompactString>,
pub domain_full: Vec<CompactString>,
pub domain_regex: Vec<String>,
pub cidr: Vec<String>,
pub source_cidr: Vec<String>,
pub port: Vec<String>,
pub network: Option<String>, // "tcp" 或 "udp"
pub inbound_tag: Vec<CompactString>,
pub geosite: Vec<CompactString>,
pub geoip: Vec<CompactString>,
}

各协议自己的键位于 [inbound.settings] 和 [outbound.settings] 之下,第一阶段把它们保留为不透明的 toml::Value。外层 schema 对所有协议都一样;lower 先按 protocol 匹配,之后才把这张表反序列化为该协议自己的结构体(inbound_settings、outbound_settings,两者都是 cfg.settings.clone().try_into())。每个 settings 结构体同样带有 deny_unknown_fields,所以对每个读取 settings 的协议,其中的拼写错误会在第二阶段以 invalid settings 前缀失败。freedom 和 blackhole 出站不读取 settings,也不反序列化任何 settings。

结构体 用于 字段(粗体为必填) 默认值
SocksInboundSettings 入站 socks auth、accounts、udp、udp_bind(一个 IP) auth = "none"、udp = true;整个结构体有默认值
HttpInboundSettings 入站 http accounts、allow_transparent allow_transparent = false;整个结构体有默认值
Account accounts 的条目 user、pass 无
TrojanInboundSettings 入站 trojan users,元素为 TrojanUserCfg { password, email } users = [];password 必填,email 默认为 ""
UuidUsersSettings 入站 vless、vmess users,元素为 IdUser { id, email } users = [];id 必填,email 默认为 ""
ShadowsocksInboundSettings 入站 shadowsocks method、password、users(别名 clients),元素为 ShadowsocksUserCfg { password, email } users = [];email 默认为 ""
Hysteria2InboundSettings 入站 hysteria2 cert_file、key_file、password、users(元素为 Hysteria2UserCfg { user, pass, email })、udp、udp_idle_timeout、max_connections、max_circuits、obfs、obfs_password、masquerade udp = false;整个结构体有默认值;每个用户的 user 和 pass 必填
Hysteria2MasqueradeCfg 子表 masquerade status、body、content_type 整个结构体有默认值;具体值在 lower_hysteria2_inbound 中应用
TunInboundSettings 入站 tun name、mtu、address、routes、udp、udp_idle_timeout、max_flows 整个结构体有默认值;具体值在 lower_tun 中应用
ProxyOutboundSettings 出站 socks、http user、pass 整个结构体有默认值
TrojanOutboundSettings 出站 trojan password 无
VlessOutboundSettings 出站 vless id 无
VmessOutboundSettings 出站 vmess id、security 无
ShadowsocksOutboundSettings 出站 shadowsocks method、password 无
Hysteria2OutboundSettings 出站 hysteria2 password、server_name、allow_insecure、ca_file、obfs、obfs_password、max_concurrent_streams allow_insecure = false
WireguardOutboundSettings 出站 wireguard private_key、peer_public_key、preshared_key、endpoint、endpoint_address_family、address(IP 列表)、mtu、keepalive、reserved(3 字节) 无

IdUser.email、TrojanUserCfg.email 和 ShadowsocksUserCfg.email 有意默认为空字符串:schema 接受缺失的 email,这样 lower 就能用一条指明入站和用户位置的消息拒绝它,而不是给出一个 serde 错误。

Hysteria 2 出站的 TLS 键(server_name、allow_insecure、ca_file)位于 settings 中,而不在 [outbound.stream.tls] 下;[stream] 中不是 tcp 的 network,或不是 none 的 security,都会被拒绝(reject_stream_settings)。Hysteria 2 不以拨号器为参数,所以它的 TLS 设置属于它自己,而不属于某个 stream。

以下默认值由 serde 自己应用:

字段 默认值 机制
InboundConfig.sniffing true default_sniffing()
InboundConfig.settings、OutboundConfig.settings 空的 TOML 表 default_settings()
ApiConfig.listen "127.0.0.1:9090" default_api_listen()
TlsConfig.allow_insecure false #[serde(default)]
每个 Option None #[serde(default)]
除 BalancerConfig.outbounds 和 WireguardOutboundSettings.address 外的每个 Vec 和 map 空 #[serde(default)]
SocksInboundSettings auth = "none"、accounts = []、udp = true、udp_bind = None 它的 Default 实现,加上结构体上的 #[serde(default)]

没有默认值的字段是必填的,缺少时是解析错误:每个 [[inbound]] 和 [[outbound]] 的 tag 与 protocol,每个 [[balancer]] 的 tag 与 outbounds,每个 [[route.rule]] 的 outbound。由于 settings 默认是空表,当 settings 结构体含有必填字段的协议省略了这张表时,会以 invalid settings: missing field … 失败,例如没有 address 的 WireGuard 出站报 outbound o: invalid settings: missing field `address` 。

其余默认值(回环 listen、WebSocket 路径 /、Hysteria 2 和 TUN 的上限)由 lower 在使用它们的检查旁边应用,列在限制中。

app/src/config.rs
#[derive(Clone, PartialEq, Default)]
pub struct Sources {
pub config: Vec<u8>,
pub subscribe: Option<Vec<u8>>,
}
pub fn load(path: &Path) -> io::Result<Config>;
pub fn parse_bytes(bytes: &[u8]) -> io::Result<Config>;
pub fn read_sources(path: &Path) -> io::Result<(Sources, io::Result<Config>)>;
pub fn sources_from(config: Vec<u8>) -> io::Result<(Sources, io::Result<Config>)>;
pub fn sources_given(config: Vec<u8>, subscribe: Option<Vec<u8>>)
-> io::Result<(Sources, io::Result<Config>)>;
pub fn effective(parsed: io::Result<Config>, sources: &Sources) -> io::Result<Config>;

Sources 保存配置文件的字节,以及订阅文件(如果有)的字节。重载如何使用它们见运行与重载。读取函数把解析结果与字节一起返回,因为只有配置解析成功后才能知道订阅文件的路径。读取失败、[subscribe] 缺少 sources_from 需要的 path、配置与交给 sources_given 的文件不一致,这些都是外层错误;无法解析的配置仍然是一组源,由 effective 报告它的解析错误。

instance.rs 为读取结果的类型起了名字,因为 Core::start 接收一个这样的值,Core::reload_with 接收一个返回它的闭包:

app/src/instance.rs
pub type Read = io::Result<(Sources, io::Result<Config>)>;
app/src/lower.rs
pub type UserKey = UserName;
pub fn lower(cfg: &Config) -> io::Result<Spec<UserKey>>;
app/src/instance.rs
pub struct Built {
pub spec: Spec<UserKey>,
pub api: Option<ApiOptions>,
}
pub fn build(cfg: &Config) -> io::Result<Built>; // 先 lower,再 api::options
pub type Lowering = Arc<dyn Fn(&Config) -> io::Result<Built> + Send + Sync>;
pub async fn check(path: &Path) -> Result<(), LoadError>;
pub async fn check_bytes(config: Vec<u8>) -> Result<(), LoadError>;
pub enum LoadError {
Config(#[from] io::Error), // #[error(transparent)]
Apply(#[from] ApplyError), // #[error(transparent)]
}
  • build 是 app 的 lowering:先 lower(cfg)?,再 api::options(cfg)?。spec 先被降出来,所以入站错误会先于 [api] 错误报告。
  • Lowering 是 Core 把合并后的配置变成它要运行之物的方式。app 传入 Arc::new(build);FFI 传入一个闭包:它调用 lower,拒绝为其他主机提供服务的入站,在配置的 TUN 入站上使用平台提供的 TUN 描述符(配置中没有 TUN 入站时添加一个),并返回 api: None。它的规则见 FFI。
  • LoadError 把两种失败来源合在一起。两个变体都是 transparent,所以调用方记录的文本就是内层错误自己的文本。

UserName、Spec 以及所有 spec 类型都来自 supervisor crate,见 spec:期望状态和用户、principal(身份主体)与会话。

flowchart TB
  bytes["配置字节"] --> parse["parse_bytes"]
  parse -->|"解析成功,含 subscribe 段"| sub["读取订阅文件"]
  parse --> eff["effective"]
  sub --> eff
  eff -->|"有订阅文件"| merge["subscribe::parse 和 subscribe::apply"]
  eff -->|"无订阅文件"| builtins["subscribe::ensure_builtins,非严格"]
  merge --> lower["lower"]
  builtins --> lower
  lower --> api["api::options"]
  api --> built["Built:spec 和 api"]
  built -->|"--test、API 编辑"| check["etemenanki_supervisor::check"]
  built -->|"启动"| start["SupervisorBuilder::start"]
  built -->|"重载"| apply["Supervisor::apply"]

parse_bytes 检查字节是否为 UTF-8,然后把文本交给 toml::from_str。两种失败都是 io::ErrorKind::InvalidData:

失败 消息
不是 UTF-8 Utf8Error 的显示文本,例如 invalid utf-8 sequence of 1 bytes from index 0
TOML 语法错误、未知键、类型错误、缺少必填字段 toml 错误的显示文本;第一行是 TOML parse error at line N, column M。

load 就是 std::fs::read 加上 parse_bytes。它按文件原样解析,不合并订阅文件;init_tracing 和 Instance::watched_files 使用它。

解析只检查结构和键名。protocol = "vlesss" 或 network = "WS" 有没有意义,要到第二阶段才判定,这也是几乎所有错误消息都出自第二阶段的原因。

函数 使用方 配置字节 订阅文件字节
read_sources(path) Instance::start、Instance::reload、REST API 的读取 std::fs::read(path) 通过 sources_from
sources_from(config) read_sources、check_bytes 由参数给出 配置解析成功且含 [subscribe] 时,从 [subscribe].path 读取
sources_given(config, subscribe) FFI 由参数给出 由参数给出;不读取 [subscribe].path

sources_from 只在配置解析成功时读取订阅文件。配置含有 [subscribe] 但没有 path 时,它以 [subscribe] needs a path naming the subscribe file(InvalidInput)失败。无法读取的订阅文件以 subscribe file <path>: <os error> 失败,并保留操作系统错误的种类。

sources_given 检查两边是否一致,但只在配置解析成功时检查:

配置 订阅文件字节 结果(InvalidInput)
有 [subscribe] 无 the config has a [subscribe] section, but no subscribe file was given
无 [subscribe] 有 a subscribe file was given, but the config has no [subscribe] section to pick its routes in

给出订阅文件时配置必须有 [subscribe],没给出时配置必须没有,因为 [subscribe.routes] 是在文件定义的内容中做选择。

effective(parsed, sources) 在有解析错误时返回该错误。否则:

  • 有订阅文件字节时,它调用 subscribe::parse(错误以 subscribe: 开头,例如 subscribe: TOML parse error at line 1, column 1),然后调用 subscribe::apply。合并会把每个节点作为出站追加到配置自己的出站之后,用文件的路由组替换配置的路由规则,并在文件含有 [dns] 时设置 cfg.subscribe_dns。合并的每条规则见订阅文件。
  • 没有时,它调用 subscribe::ensure_builtins(&mut cfg, false)。当 [route].default 或任何规则指向 direct 或 blackhole、而没有出站或负载均衡器使用该 tag 时,它会在配置自己的出站之后,按这个顺序追加一个 tag 为 direct 的 freedom 出站,或一个 tag 为 blackhole 的 blackhole 出站。非严格模式下它从不失败:配置自己的某个出站或负载均衡器若持有该 tag,这个 tag 就指它,无论其协议是什么,因为 tag 和指向它的路由都是配置自己写的。

内置出站追加在配置的出站之后,所以它们从不改变哪个出站排在第一,因而也从不改变已有出站的配置的默认路由。一个完全没有 [[outbound]]、但写了 [route] default = "direct" 的配置,会得到内置的 direct 作为唯一的出站,并通过 --test。在既没有 [[outbound]] 也没有 [route].default 的配置中,第一个被追加的内置出站成为第一个出站,也就成为默认路由:有规则指向 direct 时是 direct,否则在有规则指向 blackhole 时是 blackhole。

flowchart LR
  cfg["Config,已合并"] --> def["默认 tag:第一个出站"]
  def --> dns["lower_dns"]
  dns --> ob["lower_outbound,按文件顺序逐个"]
  ob --> bal["lower_balancer,逐个"]
  bal --> rt["lower_route"]
  rt --> ib["lower_inbound,按文件顺序逐个"]
  ib --> spec["Spec"]
  1. 默认 tag。 第一个出站(合并之后)的 tag 成为路由的默认出站,除非 [route].default 指定了别的。完全没有出站时,lower 立即以 config defines no outbounds 失败。
  2. 先处理 DNS,然后按文件顺序处理出站,接着是负载均衡器、路由表,最后按文件顺序处理入站。
  3. lower_inbound 返回一个 InboundSpec;对于在配置中内联写出用户的协议,还返回一个以该入站自身 tag 为 tag 的 UserSet。这些用户集被收集到 Spec.user_sets 中。
  4. Spec.policies 为 Policies::default()。每个 InboundSpec.user_removal 和每个 OutboundSpec.drain 都是 None,每个 UserSpec.speed_limit 也是 None:app 不设置任何策略,也不设置限速。

每一步都在遇到第一个错误时返回,所以这个顺序决定了运维者看到哪个错误。同时含有未知入站协议和未知出站协议的配置报告的是出站;错误的 [dns] 又在两者之前报告。同时含有错误 CIDR 和未知出站的规则报告的是 CIDR,因为引用由 supervisor 检查,而 supervisor 永远看不到 lowering 失败的 spec。

函数 签名 作用
invalid fn invalid(msg: String) -> io::Error 带 msg 的 InvalidInput 错误
within fn within(ctx: &str) -> impl Fn(io::Error) -> io::Error + '_ 给辅助函数的错误加上 ctx: 前缀,保留其种类。用于那些不知道自己是为哪个对象调用的代码所产生的错误:Shadowsocks 2022 的密钥函数和 Strategy::parse。
read_file fn read_file(ctx: &str, key: &str, path: &str) -> io::Result<Vec<u8>> std::fs::read,失败时为 <ctx>: cannot read <key> "<path>": <os error>,并保留操作系统错误的种类
parse_host fn parse_host(host: &str) -> Remote 文本能解析为 IP 地址时为 Remote::IpAddr,否则为 Remote::Domain
parse_uuid fn parse_uuid(ctx: &str, id: &str) -> io::Result<Uuid> Uuid::parse_str,失败时为 <ctx>: invalid uuid "<id>": <error>
parse_family fn parse_family(ctx: &str, key: &str, raw: Option<&str>) -> io::Result<AddressFamilyStrategy> 缺省为 Auto。否则调用 AddressFamilyStrategy::from_str,并把它自己的错误(unknown address family strategy)替换为 <ctx>: invalid <key> "<value>"
parse_cidr fn parse_cidr(s: &str) -> io::Result<IpCidr> 路由规则的 cidr 或 source_cidr 值,失败时为 invalid cidr "<value>": <parser error>
parse_network fn parse_network(s: &str) -> io::Result<routing::TargetNetwork> 路由规则的 network:只能是 tcp 或 udp,否则为 invalid rule network "<value>" (expected "tcp" or "udp")
lower_obfs fn lower_obfs(ctx, obfs, obfs_password) -> io::Result<Option<ObfsSpec>> 两侧的 Hysteria 2 混淆;见下文
verify_mode fn verify_mode(ctx, prefix, allow_insecure, ca_file) -> io::Result<VerifyMode> 客户端如何校验它拨号的服务端;见下文

AddressFamilyStrategy::from_str(protocols/src/helpers/address_family.rs)在匹配前先去掉值两端的空白、转为小写,并把 - 换成 _:

值 策略
缺省、""、auto Auto
ipv4、v4、4、ipv4_only、ipv4only Ipv4Only
ipv6、v6、6、ipv6_only、ipv6only Ipv6Only
prefer_ipv4、prefer_v4、ipv4_prefer、v4_prefer PreferIpv4
prefer_ipv6、prefer_v6、ipv6_prefer、v6_prefer PreferIpv6

lower_obfs 拒绝它不认识的 obfs,也拒绝没有 obfs 的 obfs_password,否则两者都会悄悄变成不混淆,运维者得到的节点能用,却很容易被识别出指纹:

obfs obfs_password 结果
缺省 缺省 None
缺省 已设置 <ctx>: obfs_password is set but obfs is not; did you mean obfs = "salamander"?
salamander 已设置或缺省 ObfsSpec::Salamander { psk },即密码的字节,缺省时为空
其他任何值 任意 <ctx>: unknown obfs "<value>" (expected "salamander")

密钥长度(至少 MIN_SALAMANDER_PSK = 4 字节,supervisor/src/build/validate.rs)是 supervisor 的规则:缺省或过短的密码可以降为 spec,随后 --test 以 inbound in: salamander obfs key must be at least 4 bytes 拒绝它。

verify_mode 接收一个 prefix,表示这些键在配置中的位置:在 [stream] 下为 tls.,在 Hysteria 2 的 settings 中为空。它的消息按配置中的写法指明键名。

allow_insecure ca_file VerifyMode
true 已设置 拒绝:<ctx>: <prefix>allow_insecure and <prefix>ca_file cannot both be set
true 缺省 Insecure
false 已设置 CustomCa(bytes),文件由 read_file 以键名 <prefix>ca_file 读取
false 缺省 System
app/src/lower.rs
fn lower_dns(cfg: &Config) -> io::Result<DnsSpec>;
fn lower_single_dns(dns: &DnsConfig) -> io::Result<Backend>;
  • 有 subscribe_dns 时,lower_dns 构建 DnsSpec::Split:pre_proxy 和 through_proxy 中的每个 URL 都经过 Backend::from_url,use_hosts 和 resolve_ipv6 照搬。配置的 [dns] 完全不读取,连它的 ca_file 也不读。URL 的写法和文件中的 connect_ipv6 见订阅文件。
  • 没有时,lower_single_dns 在设置了 ca_file 时读取它,无论后端是什么(dns: cannot read ca_file "<path>": <os error>),然后调用 Backend::from_spec。结果是 DnsSpec::Single(backend)。没有 [dns] 时,结果是 Single(Backend::System)。

Backend::from_spec(protocols/src/dns/mod.rs)把后端默认为 system,并拒绝以下情况,全部为 InvalidInput:

问题 消息
udp、tls 或 https 缺少 server dns: the udp backend needs a server address(tls、https 同理)
server 不是 IP:port 形式的 socket 地址 dns: invalid server address: invalid socket address syntax
tls 缺少 server_name dns: the tls backend needs a server name to verify against
https 缺少 url dns: the https backend needs the resolver's url
url 不以 https:// 开头 dns: "<url>" is not an https:// url
url 的 authority 为空或含 userinfo 部分 dns: "<url>" has no usable host
url 的 authority 有其他格式错误 dns: "<url>" has no host、has an invalid port、has an unclosed [ 或 has junk after ]
其他任何后端 dns: unknown backend "<value>" (expected "system", "udp", "tls" or "https")

没有路径的 url 会得到 /dns-query。每个后端只读取自己的键:server_name 属于 tls,url 属于 https,CA 字节也只为这两者保留。https 拨号到 server;它从 URL 中取出主机名(用作 TLS server name 和 Host 头)以及路径。对 tls 和 https 后端,CA 字节在 supervisor 构建解析器时才解析,所以不含证书的文件能通过 lowering,但会让 --test 以 building dns failed: no certificate in CA PEM bundle 失败。URL 规则见 DNS 解析器,解析器如何构建见名称解析与 DNS 服务。

app/src/lower.rs
fn lower_outbound(cfg: &OutboundConfig) -> io::Result<OutboundSpec>;

每个出站都降为 OutboundSpec { tag, protocol, drain: None }。上下文 ctx 为 outbound <tag>。

protocol 步骤(按顺序) OutboundProtocolSpec
freedom、direct reject_outbound_stream(以 freedom 的名义);address_family Freedom { address_family }
blackhole、block reject_outbound_stream(以 blackhole 的名义) Blackhole
socks proxy_upstream;settings Socks { upstream, account }
http proxy_upstream;settings Http { upstream, account }
trojan proxy_upstream;settings Trojan { upstream, password }
vless proxy_upstream;settings;parse_uuid Vless { upstream, id }
vmess proxy_upstream;settings;parse_uuid;parse_security Vmess { upstream, id, security }
shadowsocks proxy_upstream;settings;lower_shadowsocks_outbound Shadowsocks { .. } 或 Ss2022 { .. }
hysteria2、hysteria、hy2 reject_outbound_stream(以 hysteria2 的名义);settings;lower_hysteria2_outbound Hysteria2(Hysteria2OutboundSpec)
wireguard reject_outbound_stream(以 wireguard 的名义);settings;lower_wireguard Wireguard(WireguardSpec)
其他任何值 拒绝:outbound <tag>: unknown protocol "<value>"

reject_outbound_stream 以表中给出的协议名运行 reject_stream_settings:[stream] 中不是 tcp 的 network,或不是 none 的 security,都会被拒绝。freedom 和 blackhole 从不读取 server、port 或 settings,blackhole 也不读取 address_family。wireguard 从 settings.endpoint 取对端,而不是从 server 和 port。

app/src/lower.rs
fn proxy_upstream(cfg: &OutboundConfig, ctx: &str) -> io::Result<ProxyUpstream>;
fn outbound_transport(cfg: &OutboundConfig, ctx: &str) -> io::Result<OutboundTransportSpec>;
fn upstream_dest(cfg: &OutboundConfig, ctx: &str, network: DialNetwork) -> io::Result<Destination>;

proxy_upstream 先运行 outbound_transport,再运行 upstream_dest(.., DialNetwork::Tcp),所以一个没有 server、而其 stream 需要名字的出站,报告的是 stream 的消息(outbound o: tls stream needs tls.server_name or server),而不是 missing server。upstream_dest 以 <ctx>: missing server 或 <ctx>: missing port 失败,并用 parse_host 把 server 转为 Destination。

outbound_transport 解析 address_family,调用 resolve_stream,并补齐每一处空缺,因为出站知道自己要拨号给谁。找不到名字时,配置会被拒绝,而不是去猜一个名字:用错误的名字校验证书,正是 TLS 层要避免的失败。

空缺 回退链 拒绝消息
WebSocket Host ws.host → tls.server_name → server <ctx>: ws stream needs ws.host or server
gRPC :authority grpc.authority → tls.server_name → server <ctx>: grpc stream needs grpc.authority or server
TLS server name(shape 叠加 TLS 时) tls.server_name → server <ctx>: <network> stream needs tls.server_name or server,其中 <network> 为 tls、ws+tls 或 grpc+tls

无论 shape 是否叠加 TLS,host 和 authority 的回退链都适用。shape 叠加 TLS 时,用 verify_mode(ctx, "tls.", ..) 构建 ClientTlsSpec { server_name, verify }。结果是 OutboundTransportSpec { shape, tls, address_family },其中每个 host 和 authority 都是 Some:supervisor 会检查出站传输层没有空缺,所以这个函数是唯一填补它们的地方。a_server_config_lowers_to_its_spec 固定了一个 host 和 SNI 都取自 tls.server_name 的 WebSocket 出站。

proxy_account 把 ProxyOutboundSettings 转为 Option<Account>:缺少 user 时为 None,否则为 Account { user, pass },pass 默认为空字符串。

parse_security 把 VMess 的 security 转为小写:缺省、auto 和 aes-128-gcm 得到 Security::Aes128Gcm,chacha20-poly1305 得到 Security::ChaCha20Poly1305,其他值以 <ctx>: unknown vmess security "<lower-cased value>" 失败。

lower_shadowsocks_outbound 按前缀 2022- 选择方法族:

  • 传统 AEAD。 SsMethod::from_name 先把名字转为小写,接受 aes-128-gcm(或 aead_aes_128_gcm)、aes-256-gcm(或 aead_aes_256_gcm)、chacha20-poly1305(或 chacha20-ietf-poly1305、aead_chacha20_poly1305)以及 xchacha20-poly1305(或 xchacha20-ietf-poly1305)。其他名字:<ctx>: unknown shadowsocks method "<value>"。密码保存为 Secret<String>。
  • Shadowsocks 2022。 Ss2022Method::from_name 只接受精确的名字:2022-blake3-aes-128-gcm(16 字节密钥)、2022-blake3-aes-256-gcm 和 2022-blake3-chacha20-poly1305(32 字节密钥)。其他名字:<ctx>: unknown shadowsocks-2022 method "<value>"。password 是一条 iPSK:…:uPSK 链:按 : 切分,每一段都经过 decode_psk(标准 base64,去掉两端空白)和 normalise_psk。最后一个密钥成为 psk,其余按书写顺序成为 identity_psks。

normalise_psk(protocols/src/ss_2022/users.rs)保留长度与方法相符的密钥,把更长的密钥折叠到该长度(fold_key:对密钥做 SHA-256 后截断),并以 shadowsocks-2022: PSK too short (<n> < <len>) 拒绝更短的密钥。base64 无效的段以 decode PSK: <error> 失败。两者都经由 within 到达运维者,例如 outbound o: decode PSK: Invalid symbol 33, offset 3.。空的 password 切分后是一个空段,所以它以 outbound o: shadowsocks-2022: PSK too short (0 < 16) 失败;循环之后的 shadowsocks-2022 password is empty 分支无法从配置触发。

lower_hysteria2_outbound 依次执行:upstream_dest(.., DialNetwork::Udp)、出站的 address_family、verify_mode(ctx, "", ..)、lower_obfs,以及 server name。

Hysteria2OutboundSpec 字段 来源
server server 和 port,作为 UDP 目标
server_name settings.server_name,否则为 server。回退路径自己的 <ctx>: missing server 无法触发,因为 upstream_dest 已经要求了 server。
password settings.password,原样照搬
verify 前缀为空的 verify_mode:outbound o: allow_insecure and ca_file cannot both be set、outbound o: cannot read ca_file "<path>": <os error>
obfs lower_obfs
max_concurrent_streams settings.max_concurrent_streams,否则为 DEFAULT_MAX_CONCURRENT_STREAMS = 102,400
address_family 出站的 address_family

空密码和 max_concurrent_streams = 0 会原样降为 spec;supervisor 以 outbound o@v0: hysteria2 password must not be empty 和 outbound o@v0: max_concurrent_streams must be at least 1 拒绝它们。

lower_wireguard 依次解析:

  1. private_key、peer_public_key 和 preshared_key,使用 wireguard::parse_key:它去掉文本两端的空白,接受 32 字节的标准 base64 或十六进制编码。lower_wireguard 把 parse_key 自己的错误(invalid WireGuard key: expected base64 or hex encoding of 32 bytes)替换为指明密钥的错误:<ctx>: invalid wireguard private_key(peer_public_key、preshared_key 同理)。
  2. endpoint,在最后一个 : 处切分(<ctx>: wireguard endpoint must be host:port),端口解析为 u16(<ctx>: invalid wireguard endpoint port),主机经过 parse_host。endpoint 是一个 UDP Destination,与其他代理服务器一样,用 supervisor 的服务器解析器解析。
  3. endpoint_address_family(<ctx>: invalid endpoint_address_family "<value>"):拨号时使用具名 endpoint 的哪一族地址。
  4. 出站自己的 address_family:隧道内部使用的地址族。

mtu 默认为 wireguard::DEFAULT_MTU = 1420,与 Xray-core 使用的值相同。address、keepalive 和 reserved 照搬。address 中是否有 address_family 所要求那一族的地址,是 supervisor 的规则:outbound o@v0: wireguard address_family ipv6_only needs an IPv6 address。

app/src/lower.rs
fn lower_balancer(cfg: &BalancerConfig) -> io::Result<BalancerSpec>;
BalancerSpec 字段 来源
tag tag
members outbounds,照搬,不做任何检查
strategy 设置时为 Strategy::parse(strategy),否则为 Strategy::Failover。名字区分大小写:failover 或 round_robin。未知的策略经由 within 失败,例如 balancer b: unknown balancer strategy "random" (expected "failover" or "round_robin")。
probe_interval probe_interval,以整秒计,否则为 DEFAULT_PROBE_INTERVAL = 30 秒
probe_timeout probe_timeout,以整秒计,否则为 DEFAULT_PROBE_TIMEOUT = 5 秒

这些默认值定义在 supervisor/src/topology/balancer.rs 中。未知的成员、空的成员列表、与出站冲突的 tag,以及 TCP 健康探测无法到达的成员,都由 supervisor 拒绝;见出站、UDP fan-out 与负载均衡器。

app/src/lower.rs
fn lower_route(cfg: &RouteConfig, first_outbound: CompactString) -> io::Result<RouteSpec>;

每个 [[route.rule]] 变成一个 RouteRuleSpec { matchers, outbound }。匹配条件按固定的种类顺序加入,与键在文件中出现的顺序无关:

顺序 键 匹配条件 在这里检查的内容
1 domain_suffix RouteMatch::DomainSuffix,每个值一个 用 to_ascii_lowercase 转为小写一次
2 domain_keyword RouteMatch::DomainKeyword 转为小写
3 domain_full RouteMatch::DomainFull 转为小写
4 domain_regex 该规则的所有模式合为一个 RouteMatch::DomainRegex(DomainRegexSet) routing::parse_domain_regexes:invalid domain regex "<pattern>": <regex error>,指出第一个无法编译的模式;若每个模式单独都能编译、合成集合时却失败,则为 domain regex set of <n> patterns: <error>
5 cidr RouteMatch::Cidr parse_cidr,解析为 IpCidr:invalid cidr "<value>": <parser error>
6 source_cidr RouteMatch::SourceCidr 同 cidr
7 inbound_tag RouteMatch::InboundTag 照搬
8 network RouteMatch::Network parse_network,只能是 tcp 或 udp:invalid rule network "<value>" (expected "tcp" or "udp")
9 port RouteMatch::PortRange(lo, hi) routing::parse_port_match:一个端口或 lo-hi,两侧都去掉空白;invalid port spec: "<value>",或 invalid port spec: "<value>" has a lower bound above its upper bound
10 geosite RouteMatch::GeoSite 照搬
11 geoip RouteMatch::GeoIp 照搬

这些消息不带前缀:它们指明的是值,而不是规则。域名在这里一次性转为小写,而不是每次匹配时再转。正则模式在这里按规则编译为一个集合,所以无效的模式会让配置失败,而不是变成一条永远不匹配的规则。

RouteSpec.default 取 [route].default,否则为第一个出站的 tag。RouteSpec 携带 geoip 和 geosite 的路径;supervisor 在编译路由时加载规则用到的集合(building route failed: a geosite matcher is used but no geosite file is configured)。路由表如何匹配见路由模型,如何编译见 plane(数据平面):为每个流选路。

app/src/lower.rs
fn lower_inbound(cfg: &InboundConfig) -> io::Result<(InboundSpec, Option<UserSet<UserKey>>)>;

每个入站都降为 InboundSpec { tag, bind, sniff: cfg.sniffing, protocol, users, user_removal: None },其中 users 是返回的用户集的 tag(即入站自己的 tag),没有用户集时为 None。

flowchart TB
  p{"protocol"}
  p -->|"tun"| tun["lower_tun"]
  p -->|"socks, shadowsocks, hysteria2"| rej["reject_inbound_stream"]
  rej --> listen["resolve_listen"]
  p -->|"http, trojan, vless, vmess"| listen
  listen -->|"socks, shadowsocks, hysteria2"| own["settings 与各协议自己的 lowering,见下表"]
  listen -->|"http, trojan, vless, vmess"| users["settings,然后是 users"]
  users --> unix{"Unix 监听?"}
  unix -->|"是"| plain["reject_stream_settings;shape 为 Tcp,无 TLS"]
  unix -->|"否"| rs["resolve_stream"]
  rs --> tls["TLS shape:读取 tls.cert_file 和 tls.key_file"]
  p -->|"wireguard 及其他任何值"| no["拒绝"]
protocol 步骤(按顺序) BindSpec InboundProtocolSpec 用户集
tun lower_tun Tun(TunSource::Create(DeviceSpec)) Tun(TunSpec) 无
socks reject_inbound_stream;resolve_listen;settings;auth Tcp 或 Unix Socks { udp, udp_bind } auth = "password":对各账户调用 account_set,以 user 为 key。auth = "none":无。其他值:<ctx>: unknown socks auth "<value>"。
http resolve_listen;settings;账户;inbound_transport Tcp 或 Unix Http { allow_transparent, transport } 有账户时对各账户调用 account_set,以 user 为 key;否则无(开放模式)
trojan resolve_listen;settings;用户;inbound_transport Tcp 或 Unix Trojan { transport } 总是有,以 email 为 key,凭据为 password
vless、vmess resolve_listen;settings;用户(先 email,再 parse_uuid);inbound_transport Tcp 或 Unix Vless { transport } 或 Vmess { transport } 总是有,以 email 为 key,凭据为 uuid
shadowsocks reject_inbound_stream;resolve_listen;settings;方法和密钥;用户 Tcp 或 Unix Ss2022 { method, psk } 或 Shadowsocks { method, password } 有用户时为这些用户,以 email 为 key;否则无(共享模式)
hysteria2、hysteria、hy2 reject_inbound_stream;resolve_listen,结果必须是 IP;settings;lower_hysteria2_inbound Udp { host, port } Hysteria2(Hysteria2InboundSpec) 有用户时为这些用户,以 email 为 key,否则以 user 为 key
wireguard 拒绝:<ctx>: wireguard cannot be used as an inbound (no server implementation)
其他任何值 拒绝:<ctx>: unknown protocol "<value>"

协议在读取 listen 和 port 之前匹配,所以一个 protocol 未知且没有 port 的入站报告的是未知协议。Trojan、VLESS 和 VMess 没有开放模式或共享模式,所以它们总是指定一个用户集,哪怕是空的。需要用户集的协议是否有用户集、其中的凭据是否冲突,是 supervisor 的规则。

app/src/lower.rs
enum Listen {
Ip { host: String, port: u16 },
Unix(PathBuf),
}
fn resolve_listen(cfg: &InboundConfig) -> io::Result<Listen>;
fn tcp_bind(listen: Listen) -> BindSpec;
listen port 结果 拒绝消息
以 / 开头 必须缺省 Listen::Unix(path) inbound <tag>: a unix socket listen has no port; remove port
以 @ 开头 任意 拒绝 inbound <tag>: abstract unix sockets are not supported; use a filesystem path
其他任何值 必填 Listen::Ip { host, port } inbound <tag>: port is required
缺省 必填 Listen::Ip { host: "127.0.0.1", port } inbound <tag>: port is required

只有以 / 开头的值才是 Unix 路径;./x.sock 是主机名。主机保留为文本,既不解析也不检查:本机没有的地址、已被占用的端口或特权端口都能降为 spec 并通过 --test,要到 supervisor 绑定时才失败。tcp_bind 把 Listen::Ip 映射为 BindSpec::Tcp,把 Listen::Unix 映射为 BindSpec::Unix。Hysteria 2 从 Listen::Ip 构建 BindSpec::Udp,并以 inbound <tag>: hysteria2 listens on UDP and cannot use a unix socket 拒绝 Listen::Unix。BindSpec 的显示形式用于日志和绑定错误:TCP 为 <host>:<port>,此外还有 udp <host>:<port>、unix:<path>、tun <name>(或 tun auto)和 tun fd <n>;见监听器与服务循环。

app/src/lower.rs
fn inbound_transport(cfg: &InboundConfig, listen: &Listen, proto: &str)
-> io::Result<InboundTransportSpec>;
  • Unix 监听器。 Unix socket 是本地的,在它上面叠加 TLS、WebSocket 或 gRPC「只是在对自己伪装」。这个函数以协议名 <proto> over a unix socket 运行 reject_stream_settings(例如 inbound in: protocol vless over a unix socket does not support stream network "ws"),并返回不带 TLS 的 StreamShape::Tcp。不读取任何证书。
  • IP 监听器。 resolve_stream 给出 shape。当 shape.uses_tls() 为真时,tls.cert_file 和 tls.key_file 都必须设置(<ctx>: tls stream needs tls.cert_file,然后是 tls.key_file),两个文件都用 read_file 读取:inbound in: cannot read tls.cert_file "/etc/etemenanki/missing.pem": No such file or directory (os error 2)。结果是 ServerTlsSpec { cert_pem, key_pem: Secret }。

PEM 字节不在这里解析:单元测试往文件里写的是 CERT BYTES 和 KEY BYTES,lowering 照样接受。supervisor 在构建 handler(处理器)时才解析它们(building inbound in failed: no certificate in PEM bundle)。WebSocket 入站按原样保留 host,因为入站没有可以借用名字的来源;gRPC 入站的 authority 在入站一侧没有意义。

先运行 reject_inbound_stream(cfg, "shadowsocks"),然后是 resolve_listen 和 settings。

  • method 以 2022- 开头。 Ss2022Method::from_name(<ctx>: unknown shadowsocks-2022 method "<value>")。服务端的 password 经过 decode_psk 和 normalise_psk,因此在这里就按方法确定长度(inbound in: decode PSK: …、inbound in: shadowsocks-2022: PSK too short (16 < 32))。每个用户的密码只做解码(InlineUsers::ss2022_psk,错误形如 inbound in: users[0]: decode PSK: …),存为 Credentials.ss2022_psk。它的长度由准入该用户的入站确定,因为长度取决于该入站的方法;过短的用户密钥会在 --test 中被 supervisor 以 inbound in: user a@example.com: shadowsocks-2022: PSK too short (16 < 32) 拒绝。
  • 其他情况。 SsMethod::from_name(<ctx>: unknown shadowsocks method "<value>"),服务端密码保存为 Secret,每个用户的 password 保存为 Credentials.password。

users 列表也可以写作 clients,与 Xray 接受的写法一致。没有用户时,只用服务端密码,即共享模式,不返回用户集。

app/src/lower.rs
fn lower_hysteria2_inbound(cfg: &InboundConfig, ctx: &str, s: Hysteria2InboundSettings)
-> io::Result<(InboundProtocolSpec, Option<UserSet<UserKey>>)>;
步骤 规则 拒绝消息
证书 cert_file 和 key_file 都要设置,然后读取两者。证书是它成为 TLS 服务端的前提,所以没有默认值。 <ctx>: hysteria2 needs both cert_file and key_file;<ctx>: cannot read cert_file "<path>": <os error>(key_file 同理)
用户 仅当 users 非空时。设置了 email 的用户以 email 为 key,否则以 user 为 key,Credentials.account = Account { user, pass }。key 保留 user 的原始写法;服务端会把用户名转为小写,并以不区分大小写的方式比较客户端发来的用户名(protocols/src/hysteria/server/authenticator.rs)。 <ctx>: users[<i>] has no user,以及重名错误
伪装 三个字段都缺省:None。设置了任意一个:MasqueradeSpec,未写的字段取 status 404、body "404 page not found\n" 和 content_type "text/plain; charset=utf-8",也就是未做配置的 Go 服务器给出的应答 状态码由 supervisor 检查
混淆 lower_obfs 同上文的表格
UDP udp = true:udp_idle_timeout 以秒计,缺省为 60(lower_hysteria2_inbound 中的字面量,不是具名常量)。udp = false:udp_idle_timeout 必须缺省,因为为一个关闭的中继设置超时,多半说明运维者本想打开 UDP。 <ctx>: udp_idle_timeout is set but udp is not enabled
上限 max_connections,否则为 DEFAULT_MAX_CONNECTIONS = 262,144;max_circuits,否则为 DEFAULT_MAX_CIRCUITS = 4,194,304 由 supervisor 检查

password 和 users 都原样降为 spec:两者恰好设置其一,是 supervisor 的规则(inbound in: a shared password and a user set cannot both be set; a credential would have two answers、inbound in: hysteria2 needs a shared password or a user set)。空闲超时的 2 到 600 秒范围、salamander 密钥长度、至少为 1 的上限,以及账户规则(inbound <tag>: user <name>: a hysteria2 account needs a name without ':' and a password)同样由 supervisor 负责。app 总是设置 user_auth: Hysteria2UserAuth::Account,所以用户以 user:pass 认证。协议的服务端见 Hysteria 2:服务端。

app/src/lower.rs
fn lower_tun(cfg: &InboundConfig) -> io::Result<(BindSpec, InboundProtocolSpec)>;

TUN 入站拥有的是一个网络接口,而不是 socket。lower_tun 先拒绝 [stream] 中不是 tcp 的 network 或不是 none 的 security(reject_inbound_stream:protocol tun does not support stream network …),再拒绝 listen 或 port(<ctx>: tun owns a network interface and has no listener; remove listen/port),然后解析 settings:

设置 降为 默认值 拒绝消息
name DeviceSpec.name None:由内核选择
mtu DeviceSpec.mtu tun::DEFAULT_MTU = 1500 下限 MIN_TUN_MTU = 1280 由 supervisor 负责:inbound t: tun mtu must be at least 1280
address DeviceSpec.addresses,每个 cidr::IpInet 转为 (address, prefix length);允许主机位 无 <ctx>: bad tun address "<value>": <error>
routes DeviceSpec.routes,每个 cidr::IpCidr 转为 (network, prefix length);不允许主机位 无 <ctx>: bad tun route "<value>": <error>,例如 host part of address was not zero
udp TunSpec.udp true
udp_idle_timeout TunSpec.udp_idle_timeout,以秒计 DEFAULT_UDP_IDLE_TIMEOUT = 60 秒
max_flows TunSpec.max_flows DEFAULT_MAX_FLOWS = 65,536

结果是 BindSpec::Tun(TunSource::Create(device))。平台是否支持 routes 是 supervisor 的规则,MTU 下限也是:路由只在 Linux 上安装,在其他平台上 validate 通过 tun::check_platform 拒绝非空的 routes(tun routes are installed only on Linux; add them with the OS route tool)。--test 不创建设备。FFI 把这个绑定替换为基于手机所提供描述符的 TunSource::Fd;见 FFI 和 TUN。

UserKey 就是 supervisor/src/entity/user.rs 中的 UserName:

supervisor/src/entity/user.rs
pub enum UserName {
Email(CompactString),
Username(CompactString),
}

app 用配置中已经给出的名字作为每个用户的 key:

协议 key Credentials 中的凭据
Trojan Email(email) password
Shadowsocks(传统) Email(email) password
Shadowsocks 2022 Email(email) ss2022_psk,已解码
VLESS、VMess Email(email) uuid
SOCKS、HTTP Username(user) account
Hysteria 2 设置了 email 时为 Email(email),否则为 Username(user) account

该类型的注释给出了理由。key 写在配置中,所以每次重载都相同:没有变化的用户仍是用户集中的同一个条目,改了密码的用户是换了新凭据的同一个用户,而不是删掉一个、再加一个。key 是名字而从不是凭据,所以日志和用量报告可以携带它。

InlineUsers 为每个入站构建一个用户集:

app/src/lower.rs
struct InlineUsers {
ctx: String, // "inbound <tag>"
list: &'static str, // "users" 或 "accounts"
set: UserSet<UserKey>, // tag = 入站的 tag
listed_at: HashMap<UserKey, usize>, // 每个 key 第一次出现的位置
}
方法 作用 拒绝消息
new(cfg, list) 一个以入站 tag 为 tag 的空用户集
entry(at) 单个条目的上下文:inbound <tag>: <list>[<at>],即 users[<at>] 或 accounts[<at>]
email(at, email) UserName::Email inbound <tag>: users[<i>] has no email; a user of this inbound is named by its email
username(at, user) UserName::Username inbound <tag>: accounts[<i>] has no user(Hysteria 2 为 users[<i>])
add(at, key, credentials) 插入 UserSpec { credentials, speed_limit: None },并记下 at inbound <tag>: <list>[<first>] and <list>[<at>] are both named "<name>"
uuid(at, id) Credentials { uuid, .. } inbound <tag>: users[<i>]: invalid uuid "<id>": <error>
ss2022_psk(at, password) Credentials { ss2022_psk, .. },已解码,未定长 inbound <tag>: users[<i>]: decode PSK: <error>
into_set() 完成的 UserSet

用户集是一个 map,所以占用同一 key 的第二个用户会悄悄替换第一个;add 拒绝这种情况,并指明两个条目,而从不给出凭据。两个不同名字出示同一凭据,是入站准入什么的问题,归 supervisor 管(inbound in: users a@example.com and b@example.com present the same credential)。逐个用户条目的错误按列表顺序检查;对 VLESS 和 VMess,先检查 email,再检查 UUID。

with_password(password) 和 with_account(user, pass) 构建另外两种凭据;其中的密码都包装在 Secret 中。

SOCKS 和 HTTP 的账户经过 account_set(cfg, accounts):一个列表名为 accounts 的 InlineUsers,每个条目以 username(at, &a.user) 为 key,用 with_account(&a.user, &a.pass) 添加。

共享的 stream 规则:app/src/transport.rs

Section titled “共享的 stream 规则:app/src/transport.rs”

入站传输层和出站传输层的 lowering 几乎一模一样,只写进其中一边的规则,另一边就会缺失。所以读取 [.stream] 块的三个函数放在同一个模块中,两边都调用它们。它们产出的 shape 是 supervisor 的 StreamShape(supervisor/src/topology/spec_plan/transport.rs),带有 uses_tls()。

app/src/transport.rs
pub fn tls_layer(network: &str, security: Option<&str>, ctx: &str) -> io::Result<bool>;
pub fn resolve_stream(stream: &StreamConfig, ctx: &str) -> io::Result<StreamShape>;
pub fn reject_stream_settings(stream: &StreamConfig, proto: &str, ctx: &str) -> io::Result<()>;

ctx 指明对象(inbound <tag> 或 outbound <tag>),并作为每条消息的前缀。所有错误都是 InvalidInput。

tls_layer 决定是否在 network 之下叠加 TLS。它针对 network 校验(去掉两端空白后的)security,而不是简单地与 "tls" 比较。如果因为字符串不匹配就走了明文分支,构建出的监听器或拨号器就会与运维者的意图相悖,唯一的症状是握手失败,而那时代理凭据已经发到了线路上:Trojan 发送的是 hex(SHA224(password)),VLESS 发送的是原始 UUID。

network security 缺省、"" 或 none security = "tls" 其他任何 security
tcp 明文 拒绝:<ctx>: security = "tls" is not valid with network = "tcp"; use network = "tls" for TLS over plain TCP (security = "tls" layers TLS under network = "ws" or "grpc") 拒绝:<ctx>: unknown stream security "<value>" (expected "tls" or "none")
tls TLS TLS;接受这个多余的值,因为从 Xray 迁移来的配置习惯两个都写 拒绝,消息同上
ws、grpc 明文 TLS 拒绝,消息同上
其他任何值 Ok(false),留给调用方 Ok(false) Ok(false)

tcp + tls 这一组合是 Xray 对 TLS over TCP 的写法;过去它会构建一个明文传输层,从不读取证书。security 的值区分大小写:TLS 会作为未知值被拒绝。

resolve_stream 取 network(默认 tcp),调用 tls_layer,然后构建 shape:

network StreamShape 拒绝消息
tcp Tcp
tls Tls
ws Ws { path: ws.path or "/", host: ws.host, tls }
grpc Grpc { service: grpc.service_name, authority: grpc.authority, tls } <ctx>: grpc stream needs grpc.service_name
其他任何值 <ctx>: unknown stream network "<value>"

未知的 network 在这里以这条消息报告,所以即使 security 也写错了,tls_layer 也不去管它。tls_layer 和 resolve_stream 会去掉 security 两端的空白,但不处理 network,并且默认值只在键缺省时适用:network = " ws" 以 outbound o: unknown stream network " ws" 失败,network = "" 也一样。

用于从不使用传输层的协议。过去,配置的 network 和 security 会被悄悄丢弃,于是以为某个入站伪装成了 WebSocket 的运维者,实际得到的是一个裸端口。这个函数接受缺省、"" 或 tcp 的 network,以及缺省、"" 或 none 的 security(两者都去掉两端空白),其余一律拒绝:

  • <ctx>: protocol <proto> does not support stream network "<value>"
  • <ctx>: protocol <proto> does not support stream security "<value>"

它只检查这两个键。调用方有:用于 socks、shadowsocks、hysteria2 和 tun 入站的 reject_inbound_stream,用于每个 Unix 监听器的 inbound_transport(proto = <protocol> over a unix socket),以及用于 freedom、blackhole、hysteria2 和 wireguard 出站的 reject_outbound_stream。传输层本身见传输层:TCP 与 TLS 和传输层:WebSocket 与 gRPC。

app/src/main.rs
/// A minimal Xray-core-style proxy runtime.
#[derive(Parser)]
#[command(version, about)]
struct Args {
/// Path to the TOML configuration file.
#[arg(short, long, default_value = "config.toml")]
config: PathBuf,
/// Validate the configuration and exit without binding any listener.
#[arg(long)]
test: bool,
}

#[command(version, about)] 为二进制程序提供 --version(etemenanki-app 2.1.1),以及以文档注释开头的 --help;上面的文档注释就是 --help 输出的文本,所以保留原文。main 解析参数、初始化 tracing,带 --test 时调用 instance::check(&args.config)。init_tracing 在 RUST_LOG 已设置且能解析时使用它;只有在它未设置或无效时,才用 config::load 读取文件以获取 [log].level,而此时无法读取或解析的配置会得到 info。见运行与重载。检查过程:

  1. check 读取文件(std::fs::read),然后调用 check_bytes。
  2. check_bytes 运行 config::sources_from(它解析配置并读取配置指定的订阅文件),然后运行 config::effective 和 instance::build(后者降出 spec 并构建 [api] 选项)。
  3. 它把 spec 交给 etemenanki_supervisor::check,后者校验 spec,并在不绑定的情况下运行一次应用(apply)的构建那一半:它创建一个用完即弃的 actor(Actor::new(SocketOptions::default())),针对该 actor 空的运行状态做规划,然后以 bind = false 调用 prepare。

成功时,main 用 println! 打印 Configuration OK.,并以 ExitCode::SUCCESS 退出。失败时,它通过 tracing 在 ERROR 级别记录 configuration invalid: <error>,并以 ExitCode::FAILURE(状态码 1)退出。两者都输出到 stdout:tracing_subscriber::fmt() subscriber 写到那里,消息前带有时间戳和 etemenanki_app target;除非设置了 NO_COLOR,否则无论 stdout 是不是终端,都带 ANSI 颜色码。由于失败行经由 tracing 输出,一个过滤掉 ERROR 的 RUST_LOG(空值或 off)只会留下退出状态码。

$ etemenanki-app --test -c config.toml
Configuration OK.

--test 读取和构建了什么,又把什么留给了真正的启动:

--test 覆盖 不覆盖
配置文件,以及它指定的订阅文件 绑定 TCP、UDP 和 Unix 监听器:端口被占用、本机没有的地址、特权端口、Unix 路径上的非 socket 文件
lowering 读取的每个文件:shape 叠加 TLS 时入站的 tls.cert_file 和 tls.key_file、Hysteria 2 入站的 cert_file 和 key_file、叠加 TLS 的拨号器和 Hysteria 2 出站的 ca_file,以及 [dns].ca_file(除非被订阅文件的 [dns] 取代) 创建 TUN 设备并安装其路由
本页的每条 lowering 规则,以及 validate 的每条规则 在 socket 上打开 Hysteria 2 endpoint,以及准备 TUN 设备
解析 spec 中的证书、私钥和 CA 证书包(tls 和 https 后端的 DNS CA);Hysteria 2 的 QUIC 服务端配置和伪装 网络上的任何事:解析上游、连接上游、探测负载均衡器成员
解析器、出站、负载均衡器(不含探测任务)、结合规则所用 geodata 文件编译出的路由、handler 和用户表 启动 REST API 监听器。[api] 会被解析和检查,但不会绑定。
[api] 选项 运行中的 supervisor 会作为中断性变更拒绝的改动:check 从空状态开始规划

所以除了绑定时才做的那些,启动和 --test 在每条规则、每个构造步骤上都一致。试运行本身的逐步过程见校验与应用错误。

以下文本用核对版本的二进制程序通过 --test 捕获(每一行都跟在 configuration invalid: 之后):

前缀 产生方 示例
无 check 或 read_sources,读取配置文件时 No such file or directory (os error 2):不指明配置文件自己的路径
无 parse_bytes TOML parse error at line 1, column 1(第一行)、invalid utf-8 sequence of 1 bytes from index 0
无 sources_from [subscribe] needs a path naming the subscribe file
subscribe file <path>: sources_from,读取订阅文件时 subscribe file /etc/etemenanki/sub.toml: No such file or directory (os error 2)
subscribe: subscribe::parse、subscribe::apply subscribe: TOML parse error at line 1, column 1(第一行)
无 lower config defines no outbounds
dns: lower_single_dns、Backend::from_spec dns: cannot read ca_file "/etc/etemenanki/dns-ca.pem": No such file or directory (os error 2)、dns: the udp backend needs a server address
outbound <tag>: lower_outbound 及其辅助函数 outbound o: ws+tls stream needs tls.server_name or server、outbound o: invalid wireguard private_key
outbound <tag>: invalid settings: outbound_settings outbound o: invalid settings: unknown field `pasword`, expected `password`
balancer <tag>: lower_balancer,经由 within balancer b: unknown balancer strategy "random" (expected "failover" or "round_robin")
无 lower_route invalid cidr "192.0.2.0/33": invalid length for network: Network length 33 is too long for Ipv4 (maximum: 32)、invalid port spec: "90-80" has a lower bound above its upper bound
inbound <tag>: lower_inbound 及其辅助函数 inbound in: port is required、inbound in: tls stream needs tls.key_file
inbound <tag>: users[<i>] 或 accounts[<i>] InlineUsers inbound in: users[0]: invalid uuid "nope": invalid character: found `n` at 0、inbound in: users[0] and users[1] are both named "a@example.com"
inbound <tag>: invalid settings: inbound_settings inbound in: invalid settings: unknown field `acounts`, expected one of `auth`, `accounts`, `udp`, `udp_bind`
[api] api::options [api] listen 0.0.0.0:9090: an API reachable from other hosts needs a secret; listen on a loopback address or a unix socket, or set one;还有 [api] listen "<value>": expected ip:port, or a path (containing a /) for a unix socket 和 [api] secret is empty

lowering 成功之后,接下来是 supervisor 的消息。完整清单见校验与应用错误;以下是它们的几种形式,用同样的方式捕获:

形式 示例
duplicate <kind> tag <tag> duplicate outbound tag direct、duplicate balancer tag direct
<from> references unknown <kind> <tag> route references unknown outbound nowhere、balancer b references unknown outbound missing
balancer <tag> … balancer b has no members、balancer b: outbound h has no upstream a TCP health probe can reach
inbound <tag>: <reason> inbound t: tun mtu must be at least 1280、inbound in: socks over a unix socket has no local IP for UDP associate; set udp_bind or turn udp off
某条出站规则 outbound o@v0: wireguard address_family ipv6_only needs an IPv6 address
building <resource> failed: <error> building outbound o@v1 failed: no certificate in CA PEM bundle、building dns failed: no certificate in CA PEM bundle

lowering 和 supervisor 都给入站的消息加上 inbound <tag>: 前缀。lowering 的文本就是本页列出、在 app/src/lower.rs 或 app/src/transport.rs 中产生的那些,例如 inbound in: port is required;supervisor 的文本在 supervisor/src/build/validate.rs 中产生,列在校验与应用错误中,例如 inbound t: tun mtu must be at least 1280。在这些文件中搜索该文本,就能知道是哪一侧拒绝的。

正则错误是唯一会延续到后续行的 lowering 消息:invalid domain regex "(unclosed": regex parse error: 之后是 regex crate 自己输出的几行。invalid settings 消息以换行符结尾,日志中表现为它后面的一个空行。

每个调用方在记录日志时加上自己的上下文:

调用方 日志行
--test configuration invalid: <error>
首次启动 failed to start: <error>,退出状态码 1
重载 重载失败时记录的日志行见运行与重载
REST API 编辑 无;错误成为 HTTP 应答。见 REST API。
种类 产生于
InvalidData 来自 parse_bytes 的非 UTF-8 字节和 TOML 错误
InvalidInput lower 及其路由解析函数(parse_port_match、parse_domain_regexes)、transport.rs、sources_from(缺少路径)、sources_given、subscribe、api::options、Backend::from_spec、Strategy::parse 所做的每一次拒绝,以及 settings 的反序列化
操作系统错误自己的种类 无法读取的文件:read_file 和订阅文件在添加上下文的同时保留 e.kind();within 保留它所包装错误的种类

种类决定了 REST API 如何归类一次 lowering 失败:InvalidData 和 InvalidInput 是配置的问题,其他都不是。映射由 impl From<LoadError> for ControlError 完成;见 REST API。

不变量 机制 由哪些测试固定
拼错的键是错误,从不是默认值 每个段的结构体上,以及每个读取 settings 的协议的 settings 结构体上的 #[serde(deny_unknown_fields)] a_mistyped_key_is_rejected_rather_than_ignored(app/tests/unit/config.rs:一个入站键、一个 settings 键、一个表名、一个 TLS 键);syntax_errors_are_refused(invalid settings、allow_insecrue)
没有 listen 的入站绑定回环地址 resolve_listen 把主机默认为 127.0.0.1 a_server_config_lowers_to_its_spec(vless-in 入站)
未知或自相矛盾的 security 从不会构建出明文 tls_layer 针对 network 校验 app/tests/unit/transport.rs 中从 tcp_without_security_is_plaintext 到 an_unknown_network_is_left_to_the_caller 的每个测试
协议无法兑现的 network 或 security 会被拒绝,而不是被丢弃 reject_stream_settings a_default_or_plain_tcp_stream_is_accepted、a_transport_the_protocol_cannot_honour_is_rejected、security_on_a_protocol_without_a_transport_is_rejected;syntax_errors_are_refused(两侧的 Hysteria 2)
入站和出站应用相同的 stream 规则 两者都调用 transport::resolve_stream transport.rs 的测试覆盖这些共享函数
Unix 监听器不带传输层、端口和证书 resolve_listen、inbound_transport a_unix_listen_lowers_to_a_plain_stream;syntax_errors_are_refused(has no port、abstract unix sockets)
spec 中的出站传输层没有空缺 outbound_transport 补齐 host、authority 和 SNI,否则拒绝 a_server_config_lowers_to_its_spec(host 和 SNI 取自 tls.server_name)
用户以配置写明的名字为 key,从不以凭据为 key InlineUsers::email 和 InlineUsers::username users_are_keyed_by_the_name_the_config_gives_them、a_user_without_the_name_it_is_keyed_by_is_refused
改了密码的用户保留其 key key 就是名字 a_user_keeps_its_key_when_its_password_changes
同名的两个用户被拒绝;错误指明两个条目且不含凭据 InlineUsers::add two_users_with_one_name_are_refused
配置中写的密码和私钥在 spec 中都是 Secret Secret::new 包装 app 降出的每个密码、Shadowsocks 2022 PSK、TLS 私钥、WireGuard 私钥或预共享密钥,以及 salamander 密钥 a_spec_never_prints_a_credential
解析后的配置从不被整体格式化 schema 中没有结构体派生 Debug 无;对 Config 做 {:?} 无法编译
lowering 是配置及其所读文件的纯函数 不读时钟、不访问网络、没有全局状态 lowering_twice_gives_the_same_spec
lowering 如实进行:supervisor 的规则留给 supervisor lower 中没有 tag、引用、范围或上限检查 semantic_problems_are_left_to_the_supervisor
未知的 obfs,或没有 obfs 的 obfs_password,从不意味着不混淆 lower_obfs syntax_errors_are_refused(obfs is not、unknown obfs "salamandr" (expected "salamander"))
UDP 关闭时设置 UDP 空闲超时会被拒绝 lower_hysteria2_inbound syntax_errors_are_refused(udp is not enabled)
Hysteria 2 在两侧都认它的别名 hysteria2、hysteria 和 hy2 共用一个 match 分支 hysteria2_answers_to_its_aliases
TUN 入站降为由入站创建的设备,带有 schema 的默认值 lower_tun a_tun_inbound_lowers_to_its_device;syntax_errors_are_refused(remove listen/port、bad tun route)
需要服务器的 DNS 后端有服务器 Backend::from_spec a_backend_without_its_server_is_rejected(app/tests/integration/e2e_dns.rs,经由 --test)
tls DNS 后端有用于校验的名字 Backend::from_spec a_dns_backend_needs_what_it_names
订阅文件的 [dns] 变为 split spec lower_dns a_subscribe_file_splits_dns
从磁盘读取的配置指明其订阅文件 sources_from a_subscribe_section_read_from_disk_needs_a_path
直接给出的源在是否有订阅文件这一点上保持一致 sources_given given_sources_must_agree_on_a_subscribe_file
路由指向 direct 和 blackhole 时它们存在;配置自己的同 tag 出站优先 ensure_builtins(cfg, false) direct_and_blackhole_exist_when_a_route_names_them、a_plain_configs_own_outbound_keeps_its_builtin_tag
--test 拒绝启动时会拒绝的一切(绑定除外) 相同的 effective 和 build,然后是 supervisor::check a_member_with_no_upstream_is_refused(app/tests/integration/e2e_balancer.rs,经由 --test);every_lowered_node_builds_and_the_dns_setup_with_it(app/tests/unit/subscribe.rs,先 lower 再 check)
  • 第一个错误胜出。 每个函数都返回 io::Result,每个阶段在第一个错误处停止。决定运维者看到哪个错误的顺序是:读取配置、解析配置、读取订阅文件、合并,然后是 lower(默认 tag、DNS、按文件顺序的出站、负载均衡器、按顺序的路由规则、按文件顺序的入站;在一个入站内部,按上文表格中的步骤),然后是 [api],最后是 supervisor 的 validate 和构建代码。
  • 失败的 lowering 留下了什么。 什么也没有。lower 构建的是普通数据,不打开任何东西,所以失败的 lowering 会丢弃它已构建的内容。在重载时,这就是为什么在完整的 spec 出现之前,运行中的 supervisor 不会被触动;见运行与重载。
  • 阻塞与取消。 lower、effective 和读取函数都是同步的,不含 .await,所以无法在中途取消它们。它们的文件读取是在调用线程上阻塞的 std::fs::read;check_bytes 和 Core::reload_with 在异步函数中运行它们。它们不 spawn 任务、不打开 socket、不启动定时器。etemenanki_supervisor::check 是异步的,在调用方的任务上运行;它构建的一切在它返回时被丢弃。
  • --test 会读取配置两次。 当 RUST_LOG 未设置或无效时,--test 会读取配置文件两次:一次在 init_tracing 中读取 [log].level,一次在 check 中。

解析和 lowering 过程中应用的默认值:

设置 默认值 位置
入站 listen 127.0.0.1 resolve_listen
入站 sniffing true default_sniffing
[stream].network tcp resolve_stream
WebSocket 路径 / resolve_stream
SOCKS 入站 auth、udp none、true SocksInboundSettings::default
Hysteria 2 入站 udp false Hysteria2InboundSettings
UDP 开启时 Hysteria 2 入站的 udp_idle_timeout 60 秒,字面量 lower_hysteria2_inbound
Hysteria 2 入站 max_connections DEFAULT_MAX_CONNECTIONS = 262,144 protocols/src/hysteria/server/config.rs
Hysteria 2 入站 max_circuits DEFAULT_MAX_CIRCUITS = 4,194,304 protocols/src/hysteria/server/config.rs
Hysteria 2 伪装,设置了任一字段时 状态码 404,body 为 404 page not found 加一个换行符,text/plain; charset=utf-8 lower_hysteria2_inbound
Hysteria 2 出站 max_concurrent_streams DEFAULT_MAX_CONCURRENT_STREAMS = 102,400 protocols/src/hysteria/config.rs
Hysteria 2 出站 server_name server lower_hysteria2_outbound
TUN mtu tun::DEFAULT_MTU = 1500 protocols/src/tun/config.rs
TUN udp、udp_idle_timeout、max_flows true、60 秒、65,536 protocols/src/tun/config.rs
WireGuard mtu wireguard::DEFAULT_MTU = 1420 protocols/src/wireguard/config.rs
负载均衡器 strategy failover lower_balancer
负载均衡器 probe_interval、probe_timeout 30 秒、5 秒 supervisor/src/topology/balancer.rs
路由默认出站 合并后的第一个出站 lower
[dns].backend system Backend::from_spec
DNS-over-HTTPS 路径 URL 没有路径时为 /dns-query protocols/src/dns/mod.rs
address_family、endpoint_address_family Auto parse_family
VMess security aes-128-gcm parse_security
[api].listen 127.0.0.1:9090 webclient/src/listen.rs

适用于这些值的范围和下限(Hysteria 2 UDP 空闲超时在 HY2_UDP_IDLE = 2 到 600 秒之内、TUN MTU 至少为 MIN_TUN_MTU = 1280、各上限至少为 1、salamander 密钥至少为 MIN_SALAMANDER_PSK = 4 字节,均定义在 supervisor/src/build/validate.rs 中)由 supervisor 执行,列在校验与应用错误中。整个系统的限制汇总在限制、超时与内存一页中。

单元测试是库的 #[path] 模块(app/src/config.rs → ../tests/unit/config.rs,lower.rs、transport.rs、subscribe.rs 和 api.rs 同理),所以用下面的命令运行:

终端窗口
cargo test -p etemenanki-app --lib

集成测试会启动真实的二进制程序(CARGO_BIN_EXE_etemenanki-app),用 cargo test -p etemenanki-app --test integration 运行,例如 cargo test -p etemenanki-app --test integration e2e_dns。

文件 测试 固定的行为
app/tests/unit/config.rs parses_tls_ca_file [outbound.stream.tls] ca_file 能到达结构体
a_mistyped_key_is_rejected_rather_than_ignored lisen、settings 中的 acounts、[rout] 和 ca_fil 都被拒绝
route_rules_accept_every_matcher 每个匹配条件键都解析到规则中对应的字段
direct_and_blackhole_exist_when_a_route_names_them 没有订阅文件时,direct 和 blackhole 被追加在配置的出站之后
a_plain_configs_own_outbound_keeps_its_builtin_tag 配置自己的 direct 出站(哪怕是 socks 出站)被保留,不添加任何东西
a_subscribe_section_read_from_disk_needs_a_path sources_from 以 InvalidInput 拒绝没有 path 的 [subscribe]
given_sources_must_agree_on_a_subscribe_file sources_given 不读取路径,并拒绝有段无文件、有文件无段两种情况
app/tests/unit/transport.rs tcp_without_security_is_plaintext、tcp_with_tls_is_rejected_and_names_the_fix、tls_network_carries_tls_with_or_without_a_redundant_security、ws_and_grpc_layer_tls_only_when_asked、an_unknown_security_is_rejected_on_every_network、security_is_trimmed_before_matching、an_unknown_network_is_left_to_the_caller tls_layer 矩阵,逐行覆盖
a_default_or_plain_tcp_stream_is_accepted、a_transport_the_protocol_cannot_honour_is_rejected、security_on_a_protocol_without_a_transport_is_rejected reject_stream_settings
app/tests/unit/lower.rs a_server_config_lowers_to_its_spec 每种准入用户的协议各一个入站、两个出站(proxy 和 direct)以及一个负载均衡器:绑定、shape、TLS 字节、用户 key 与凭据、服务端 SS2022 密钥被定长为 16 字节而用户密钥保持 32 字节、开放模式的 HTTP、Hysteria 2 伪装默认值、出站空缺的补齐、负载均衡器策略、小写化的域名与匹配条件顺序、DnsSpec::Single(Backend::System)、默认策略
lowering_twice_gives_the_same_spec 确定性
a_spec_never_prints_a_credential spec 的 Debug 输出不含六个测试用机密中的任何一个(三个密码、两个账户密码和 salamander 密钥),打印出的用户 key 也不含凭据
users_are_keyed_by_the_name_the_config_gives_them 上文的 key 表,包括一个带 email 的 Hysteria 2 用户
a_user_keeps_its_key_when_its_password_changes key 相同,UserSpec 不同
a_user_without_the_name_it_is_keyed_by_is_refused 全部八种情况下缺失或为空的 email 与 user,每条消息都以 inbound in: 开头
two_users_with_one_name_are_refused 指明两个位置,名字加引号,消息中没有凭据
a_subscribe_file_splits_dns subscribe_dns 变为 DnsSpec::Split
a_dns_backend_needs_what_it_names 没有 server_name 的 tls 被拒绝;带服务器的 udp 能降为 spec
syntax_errors_are_refused app 自己的拒绝,共 22 个用例,以及空配置的 config defines no outbounds
semantic_problems_are_left_to_the_supervisor 每条规则只有一个归属方中描述的分工
a_unix_listen_lowers_to_a_plain_stream 带 StreamShape::Tcp 且无 TLS 的 BindSpec::Unix
a_tun_inbound_lowers_to_its_device 带地址、路由和默认值的 TunSource::Create
hysteria2_answers_to_its_aliases 两侧的 hysteria2、hysteria、hy2,带默认的流数上限
app/tests/unit/subscribe.rs every_lowered_node_builds_and_the_dns_setup_with_it 示例订阅文件的节点和 DNS 设置(去掉了路由组,因为它们引用了测试环境中没有的 geodata)经过合并、lowering,并通过 etemenanki_supervisor::check
app/tests/unit/api.rs api_options_come_from_the_api_section、an_api_open_to_other_hosts_needs_a_secret api::options:默认 listen、Unix 路径、secret 规则、空 secret、未知键
app/tests/integration/e2e_dns.rs a_backend_without_its_server_is_rejected 对没有 server 的 backend = "udp",--test 以非零状态退出
app/tests/integration/e2e_balancer.rs a_member_with_no_upstream_is_refused 对基于 freedom 的负载均衡器,--test 以非零状态退出

单元测试把 CERT BYTES 和 KEY BYTES 写作证书文件(tls_files),这就够了,因为 lowering 只读取它们。没有专门测试的路径:Shadowsocks 2022 出站的密钥链、WireGuard 的密钥和 endpoint 消息、verify_mode 读取 CA 文件,以及 read_file 的确切消息。

其余每个集成测试也都用自己写出的配置启动二进制程序,所以都会途经这里的 lowering。e2e_unix(Unix socket 上的 SOCKS 和 HTTP 入站)、e2e_tun(创建自己设备的 TUN 入站;它需要 CAP_NET_ADMIN,没有时跳过)、e2e_hysteria_inbound(Hysteria 2 入站对接上游客户端)和 e2e_route_context(inbound_tag、source_cidr 和 network 匹配条件)端到端地检验这些 lowering 的产物;见测试。

新增检查之前,先要确定它的归属方。关于有类型的各部分如何组合的规则属于 supervisor 的 validate,测试写在 supervisor/tests/unit/validate.rs 中。关于 TOML 的规则属于 lower.rs 或 transport.rs,并在 syntax_errors_are_refused 中加一个用例,断言消息中有辨识度的一部分。