spec:期望状态
源码文件:47 个 · 核对版本 Etemenanki 555b7df · katana v4.1.1
Etemenanki/supervisor/src/topology/spec_plan/mod.rsEtemenanki/supervisor/src/topology/spec_plan/inbound.rsEtemenanki/supervisor/src/topology/spec_plan/outbound.rsEtemenanki/supervisor/src/topology/spec_plan/transport.rsEtemenanki/supervisor/src/topology/spec_plan/route.rsEtemenanki/supervisor/src/topology/spec_plan/dns.rsEtemenanki/supervisor/src/topology/spec_plan/plan.rsEtemenanki/supervisor/src/topology/inbound/mod.rsEtemenanki/supervisor/src/topology/balancer.rsEtemenanki/supervisor/src/entity/user.rsEtemenanki/supervisor/src/entity/id.rsEtemenanki/supervisor/src/policy.rsEtemenanki/supervisor/src/build/validate.rsEtemenanki/supervisor/src/build/apply.rsEtemenanki/supervisor/src/build/users.rsEtemenanki/supervisor/src/build/inbound.rsEtemenanki/supervisor/src/build/outbound.rsEtemenanki/supervisor/src/build/dns.rsEtemenanki/supervisor/src/build/route.rsEtemenanki/supervisor/src/supervisor.rsEtemenanki/supervisor/src/system/listener.rsEtemenanki/concepts/src/net.rsEtemenanki/environment/src/routing.rsEtemenanki/protocols/src/helpers/address_family.rsEtemenanki/protocols/src/transports/tls/config.rsEtemenanki/protocols/src/transports/ws/endpoint.rsEtemenanki/protocols/src/tun/device.rsEtemenanki/protocols/src/tun/config.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/hysteria/server/masquerade.rsEtemenanki/protocols/src/hysteria/server/endpoint.rsEtemenanki/protocols/src/hysteria/server/config.rsEtemenanki/protocols/src/hysteria/config.rsEtemenanki/protocols/src/wireguard/config.rsEtemenanki/protocols/src/ss_2022/crypto.rsEtemenanki/protocols/src/ss_2022/users.rsEtemenanki/app/src/lower.rsEtemenanki/app/src/transport.rsEtemenanki/ffi/src/client.rsEtemenanki/app/tests/unit/lower.rsEtemenanki/supervisor/tests/unit/validate.rsEtemenanki/supervisor/tests/unit/plan.rsEtemenanki/supervisor/tests/hot_swap.rsEtemenanki/environment/tests/unit/routing.rsEtemenanki/ffi/tests/proxy.rskatana/src/lower/mod.rskatana/src/lower/inbound.rs
Spec<U> 即 spec(期望状态):要求 supervisor(监管器)运行的全部内容,以带类型的数据写成,包括监听器及其上服务的协议、上游、负载均衡器、路由表、名称解析、入站准入的用户集,以及针对存活连接的策略。前端程序(etemenanki-app 的 TOML 加载器、katana 对面板数据的 lowering(降为 spec)、FFI 客户端)把自己的格式降为 spec,交给 Supervisor::start、apply 或 update。supervisor 校验它,对照自己已在运行的 spec 规划一次调和,只构建有变化的部分。
本页说明 supervisor/src/topology/spec_plan/ 下的每个类型,以及组成它们的、来自 topology/inbound/mod.rs、entity/user.rs 和 policy.rs 的类型:每个字段的含义、构建器如何使用它、适用哪些规则,以及两份 spec 如何比较。本页面向前端程序的作者,以及要新增字段、协议或传输层的贡献者。规则及其完整错误文本见校验与应用错误。两份 spec 之间的差异会带来什么(版本、切换、排空)见规划并应用变更。
不带 crate 目录的路径(如 build/validate.rs 或 supervisor.rs)都在 supervisor/src/ 下。其他 crate 中的路径从仓库根目录写起,如 app/src/lower.rs。
一份 spec:
- 用 tag 命名每个资源,资源之间的每个引用也都是 tag:路由指定一个出站或负载均衡器,负载均衡器指定若干出站,入站指定一个用户集;
- 每个值都带类型。它保存的是
Destination而不是主机文本,是Uuid、加密方法枚举、解码后的密钥字节和 PEM 字节,而从不是解析出它们的那段字符串。所以两份 spec 可以用==比较,plan无须过问任何一份 spec 从哪里来,就能分辨哪个资源该保留、哪个该重建; - 把机密值放在
Secret<T>中(具体字段见下文Secret<T>一节),它按值比较,打印为Secret(..); - 是字段公开的纯数据。它仅有的构造函数是
Secret::new和From<T> for Secret<T>,仅有的可能失败的辅助函数是Strategy::parse和DomainRegexSet::new,由前端程序在降为 spec 时调用。违反规则的 spec 可以构造出来;应用(apply)它时会被整体拒绝。
spec 不做的事:
- 读文件。证书、私钥和 CA 证书包由前端程序读取,传入的是它们的字节。supervisor 按 spec 构建时,有两处字段会让它读文件:
RouteSpec.geoip和geosite是由build/route.rs→compile_routes读取的路径;DnsSpec::Split { use_hosts: true }让build/dns.rs→build_dns读取系统 hosts 文件。 - 检查自己的规则。类型能表达的都由类型承担:WireGuard 密钥是
[u8; 32],限速是NonZeroU64。关于 spec 中各个值如何相互配合的规则(哪些字段必须同时出现、哪些 tag 存在、哪些值在范围内)都在build/validate.rs→validate中,它是纯函数:不碰 socket、文件和时钟。前端程序只检查它自己格式中才有的东西,例如某个键设置了、它所属的功能却关着。 - 保存运行时状态。spec 唯一可能持有的资源是外部提供的 TUN 描述符(
SuppliedTun,一个Arc<OwnedFd>)。 - 知道自己是怎么写出来的。其中没有任何东西记录文件名、行号或面板字段。
spec 从哪里来
Section titled “spec 从哪里来”| 前端程序 | lowering | U |
它设置什么 |
|---|---|---|---|
| etemenanki-app | app/src/lower.rs → lower,在合并订阅文件之后 |
lower::UserKey,即 app 为 UserName 定义的类型别名 |
每个接受用户的入站一个用户集,tag 就是该入站自己的 tag。每个用户的 speed_limit 都是 None。Policies::default(),入站和出站都没有覆盖。Hysteria2UserAuth::Account。每个出站的 WebSocket host、gRPC authority 和 SNI 都已填好。 |
| katana | src/lower/mod.rs → node_spec,每个节点一次 |
Uid,面板的数字用户 id,名字是以十进制 id 为值的 UserName::Username |
一个 tag 为 users 的用户集,每个用户的凭据都由其 UUID 派生。Policies::default()。Hysteria2UserAuth::Password,除非设置了 [node.hysteria] credential = "user_pass"。 |
| etemenanki-ffi | ffi/src/client.rs → lowering:先是 app 的 lower,然后 refuse_servers,再 supply_tun |
app 的 lower::UserKey |
app 的 spec,其中每个服务端入站都被拒绝,TUN 入站的绑定换成平台所提供描述符上的 TunSource::Fd。 |
FFI 客户端作为客户端运行在手机上,所以 refuse_servers 拒绝任何向其他主机提供代理协议服务的入站:Trojan、VLESS、VMess、Shadowsocks、Shadowsocks 2022、Hysteria 2,以及绑定在 Unix socket、localhost 或回环地址之外的 SOCKS 或 HTTP。拒绝信息为 inbound "<tag>" serves <protocol>, a server protocol; a client runs only a tun inbound and local socks or http inbounds,其中 <protocol> 例如 trojan 或 socks beyond this device。随后 supply_tun 用平台的描述符提供服务:
- 配置中有一个 TUN 入站时保留它,连同它的 MTU、UDP 中继、流数上限和嗅探设置。接口归平台所有,所以配置的名字、地址和路由会被忽略,并记一条 WARN:
inbound <tag>: the platform owns the tun interface, so its name, addresses and routes in the config are ignored。 - 配置中没有 TUN 入站时添加一个,tag 为
tun(TUN_TAG),开启嗅探和udp,其余取protocols/src/tun/config.rs的默认值。 - 配置中有多个 TUN 入站时拒绝:
the config has more than one tun inbound; the platform supplies one device。
etemenanki-app 如何把 TOML 降为 spec,见etemenanki-app:从 TOML 到 spec。katana 如何把一个节点降为 spec,见它的 lowering 页面。FFI 客户端如何改写 app 的 spec,见移动端库(etemenanki-ffi)。
Spec<U>
Section titled “Spec<U>”#[derive(Debug, Clone, PartialEq, Eq)]pub struct Spec<U: UserId> { pub inbounds: Vec<InboundSpec>, pub outbounds: Vec<OutboundSpec>, pub balancers: Vec<BalancerSpec>, pub route: RouteSpec, pub dns: DnsSpec, /// The users inbounds admit, as their own resource: a user set can be /// swapped without reconciling anything else. pub user_sets: Vec<UserSet<U>>, /// The supervisor-wide defaults, which an inbound or outbound may /// override. pub policies: Policies,}U 是前端程序本来就用来称呼其用户的 key(见 UserId 与 UserName)。它只出现在 user_sets 中;其余部分对所有前端程序都一样。Spec 没有 Default,RouteSpec 和 DnsSpec 也没有,所以前端程序总要自己指定默认路由目标和 DNS 后端。
| 字段 | 在 spec 内的身份 | 构建者 | 页面 |
|---|---|---|---|
inbounds |
tag 在入站中唯一;bind 在入站中唯一 |
build/inbound.rs → build_handler、system/listener.rs → bind |
监听器与服务循环 |
outbounds |
tag 在出站与负载均衡器中唯一 |
build/outbound.rs → build_outbound |
出站、UDP 扇出与负载均衡器 |
balancers |
tag 在出站与负载均衡器中唯一 |
topology/balancer.rs → Balancer::new |
出站、UDP 扇出与负载均衡器 |
route |
每份 spec 一个 | build/route.rs → compile_routes |
plane(数据平面):为每个流选路 |
dns |
每份 spec 一个 | build/dns.rs → build_dns |
名称解析与 DNS 服务 |
user_sets |
tag 在用户集中唯一 |
build/users.rs → admit、build/inbound.rs → user_table |
用户、principal(身份主体)与会话 |
policies |
每份 spec 一个 | 在确定排空或移除策略时读取 | 策略 |
公开路径如下:Spec、Secret、ObfsSpec 以及从私有子模块 inbound、outbound、transport、route 和 dns 重新导出的一切,在 etemenanki_supervisor::topology::spec_plan;BindSpec、TunSource 和 SuppliedTun 在 etemenanki_supervisor::topology::inbound;用户类型在 etemenanki_supervisor::entity::user;策略在 etemenanki_supervisor::policy;另外还有 etemenanki_supervisor::topology::balancer::Strategy。规划器与 spec 放在一起,位于公开子模块 spec_plan::plan。
spec 持有的、来自其他 crate 的类型:
| 类型 | 路径 | 持有者 |
|---|---|---|
Destination、DialNetwork、Remote |
etemenanki_concepts::net |
ProxyUpstream.server、Hysteria2OutboundSpec.server、WireguardSpec.endpoint |
AddressFamilyStrategy |
etemenanki_protocols::helpers::address_family |
Freedom、OutboundTransportSpec、Hysteria2OutboundSpec、WireguardSpec(两处) |
Method(导入为 SsMethod) |
etemenanki_protocols::ss_legacy |
Shadowsocks 入站和出站 |
Method(导入为 Ss2022Method) |
etemenanki_protocols::ss_2022 |
Shadowsocks 2022 入站和出站 |
VerifyMode |
etemenanki_protocols::transports::tls |
ClientTlsSpec、Hysteria2OutboundSpec |
Security |
etemenanki_protocols::vmess |
VMess 出站 |
DeviceSpec |
etemenanki_protocols::tun |
TunSource::Create |
Backend |
etemenanki_protocols::dns |
DnsSpec |
RouteMatch |
etemenanki_environment::routing |
RouteRuleSpec |
Uuid |
uuid |
Credentials.uuid、VLESS 和 VMess 出站 |
CompactString |
compact_str |
每个 tag、Account.user、UserName |
tag 与引用
Section titled “tag 与引用”tag 为资源命名,资源之间也靠 tag 相互引用:
flowchart LR inbound["InboundSpec"] set["UserSet"] rule["RouteRuleSpec"] dflt["RouteSpec 的 default"] balancer["BalancerSpec"] outbound["OutboundSpec"] inbound -->|"users:用户集 tag"| set rule -->|"outbound:tag"| outbound rule -->|"outbound:tag"| balancer dflt -->|"tag"| outbound dflt -->|"tag"| balancer balancer -->|"members:出站 tag"| outbound
- 三个命名空间。 入站 tag、用户集 tag,以及出站与负载均衡器共用的 tag。路由以同样的方式指定出站和负载均衡器,所以这两类共用一个命名空间:如果某个负载均衡器的 tag 已被一个出站占用,会以
duplicate balancer tag <tag>拒绝。入站或用户集可以与某个出站同 tag。etemenanki-app 依赖这种隔离:它给每个入站的用户集打上该入站自己的 tag。 - 不允许空 tag。 空 tag 归 supervisor 自己使用:它命名 DNS 服务所经由的目标(
supervisor.rs→internal_id),也命名 supervisor 启动时那个空 plane 中的黑洞,这样任何 spec tag 都不会与它们冲突。拒绝信息为inbound : a inbound tag must not be empty,或outbound @v0: a outbound tag must not be empty。 - 每个引用都必须能解析。 路由规则或路由默认目标必须指定一个出站或负载均衡器(
route references unknown outbound missing)。负载均衡器成员必须指定一个出站,而不能是另一个负载均衡器(balancer outer references unknown outbound inner)。入站的users必须指定一个用户集(inbound socks references unknown user set missing)。
以上错误文本是 ApplyError 的 Display 形式。全部规则按 validate 的检查顺序列在校验与应用错误。
Secret<T>
Section titled “Secret<T>”#[derive(Clone, PartialEq, Eq, Hash)]pub struct Secret<T>(T);
impl<T> Secret<T> { pub fn new(value: T) -> Self; /// The secret itself, for the code that has to put it on the wire. pub fn expose(&self) -> &T;}
impl<T> From<T> for Secret<T>;
impl<T> fmt::Debug for Secret<T> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("Secret(..)") }}Clone、PartialEq、Eq和Hash都是派生的,所以机密值按其内容比较和哈希。密码变了就是 spec 变了,持有它的资源会被重建。Debug是手写的,输出Secret(..):既不输出值,也不输出长度。Secret没有Display。- 内部的值是私有的,读取它的唯一方式是
expose()。在 supervisor 中,validate调用它来检查长度、是否为空以及是否冲突,构建器则在把值放进协议自己的配置时调用它。
Secret 在 spec 中出现的位置:
| 字段 | T |
|---|---|
InboundProtocolSpec::Shadowsocks.password、OutboundProtocolSpec::Trojan.password、OutboundProtocolSpec::Shadowsocks.password |
String |
InboundProtocolSpec::Ss2022.psk、OutboundProtocolSpec::Ss2022.psk 及其 identity_psks 中的每一个 |
Vec<u8> |
Hysteria2InboundSpec.shared_password、Hysteria2OutboundSpec.password |
String |
ServerTlsSpec.key_pem |
Vec<u8> |
ObfsSpec::Salamander.psk |
Vec<u8> |
WireguardSpec.private_key、WireguardSpec.preshared_key |
[u8; 32] |
Account.pass |
String |
Credentials.password、Credential::Password |
String |
Credentials.ss2022_psk、Credential::Ss2022Psk |
Vec<u8> |
用 Rust 写一份 spec
Section titled “用 Rust 写一份 spec”一个基于 WebSocket 和 TLS 的 Trojan 监听器,加一个 Unix socket 上的 SOCKS 监听器,两者准入同一个用户集;除一个被屏蔽的域名外,所有流量都直连:
use std::collections::BTreeMap;use std::time::Duration;
use etemenanki_environment::routing::RouteMatch;use etemenanki_protocols::dns::Backend;use etemenanki_protocols::helpers::address_family::AddressFamilyStrategy;use etemenanki_supervisor::entity::user::{Account, Credentials, UserName, UserSet, UserSpec};use etemenanki_supervisor::policy::{Policies, UserRemovalPolicy};use etemenanki_supervisor::topology::inbound::BindSpec;use etemenanki_supervisor::topology::spec_plan::*;use etemenanki_supervisor::Supervisor;
// The front end reads the files; the spec carries bytes.let cert_pem = std::fs::read("/etc/etemenanki/cert.pem")?;let key_pem = Secret::new(std::fs::read("/etc/etemenanki/key.pem")?);
// One user with two credentials: Trojan admits the password, SOCKS the account.let alice = UserSpec { credentials: Credentials { password: Some(Secret::new("replace-with-a-long-random-password".into())), account: Some(Account { user: "alice".into(), pass: Secret::new("replace-with-a-long-random-password".into()), }), ..Credentials::default() }, speed_limit: None,};
let spec: Spec<UserName> = Spec { inbounds: vec![ InboundSpec { tag: "trojan-in".into(), bind: BindSpec::Tcp { host: "0.0.0.0".into(), port: 443 }, sniff: true, protocol: InboundProtocolSpec::Trojan { transport: InboundTransportSpec { shape: StreamShape::Ws { path: "/t".into(), host: None, tls: true }, tls: Some(ServerTlsSpec { cert_pem, key_pem }), }, }, users: Some("people".into()), user_removal: None, }, InboundSpec { tag: "socks-in".into(), bind: BindSpec::Unix("/run/etemenanki/socks.sock".into()), sniff: false, protocol: InboundProtocolSpec::Socks { udp: false, udp_bind: None }, users: Some("people".into()), user_removal: Some(UserRemovalPolicy::CloseAfter(Duration::from_secs(30))), }, ], outbounds: vec![ OutboundSpec { tag: "direct".into(), protocol: OutboundProtocolSpec::Freedom { address_family: AddressFamilyStrategy::Auto }, drain: None, }, OutboundSpec { tag: "block".into(), protocol: OutboundProtocolSpec::Blackhole, drain: None }, ], balancers: Vec::new(), route: RouteSpec { rules: vec![RouteRuleSpec { matchers: vec![RouteMatch::DomainSuffix("ads.example.com".into())], outbound: "block".into(), }], default: "direct".into(), geoip: None, geosite: None, }, dns: DnsSpec::Single(Backend::System), user_sets: vec![UserSet { tag: "people".into(), users: BTreeMap::from([(UserName::Email("alice@example.com".into()), alice)]), }], policies: Policies::default(),};
let (supervisor, _report) = Supervisor::start(spec).await?;// Later: an edit of the running spec, validated and planned like any apply.supervisor.update(|spec| spec.route.default = "block".into()).await?;flowchart LR fe["前端程序:TOML、面板 API、FFI"] spec["Spec"] validate["validate"] plan["plan:与 RunningState 比较"] prepare["准备(prepare):按 spec 构建"] commit["提交(commit)"] running["RunningState.spec"] users["set_users、upsert_user、remove_user"] fe -->|"降为 spec"| spec spec --> validate --> plan --> prepare --> commit --> running running -->|"用 == 比较"| plan running -->|"update:先克隆,再编辑"| spec users -->|"编辑一个用户集,存入编辑后的 spec"| running
- 降为 spec。 前端程序解析自己的格式,读取文件,填充默认值和空缺,构建一个
Spec。凡是会因语法而失败的,都在这里失败,并作为前端程序自己的错误报告。 - 移交。
SupervisorBuilder::start和Supervisor::start接收第一份 spec,apply和apply_with接收一份完整的新 spec,update和update_with接收一个闭包FnOnce(&mut Spec<U>)。actor 克隆运行中的 spec,在副本上运行这个闭包,然后像对待其他 spec 一样应用结果。check接收&Spec<U>,构建一切但不绑定。 - 校验。
plan(running, desired)先调用validate(desired)。违反规则的 spec 被整体拒绝,什么都不规划(an_invalid_spec_is_refused_without_a_plan)。 - 规划。
plan逐个资源地比较期望的 spec 与RunningState.spec(见相等语义),生成Build、Reuse和其他步骤。计划中含有Disrupt步骤时,除非这次应用允许中断,actor 会拒绝它。 - 准备与提交。 准备(prepare)阶段按计划从 spec 构建:
build_dns、build_outbound、compile_routes(它读取 geo 文件),以及build_handler,后者解析证书和私钥,并用user_table构建入站的用户表。它还为新的绑定打开监听器。提交(commit)阶段让这一切生效,并把期望的 spec 存为RunningState.spec。 - 用户编辑。
set_users、upsert_user和remove_user在运行中 spec 的副本里修改一个用户集(Actor::edit_users)。对每个引用该用户集的入站,actor 运行validate_admission(而不是整个validate),并重建该入站的用户表。只有每张表都建好之后,它才存入这些表,并把编辑后的副本存为运行中的 spec。对用户集中不存在的用户调用remove_user会返回false,不做任何改变。之后的update从编辑后的用户集开始,之后的apply拿自己的用户集与编辑后的用户集比较。
每个阶段的完整过程见规划并应用变更。
InboundSpec
Section titled “InboundSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct InboundSpec { pub tag: CompactString, pub bind: BindSpec, pub sniff: bool, pub protocol: InboundProtocolSpec, pub users: Option<CompactString>, pub user_removal: Option<UserRemovalPolicy>,}| 字段 | 含义 |
|---|---|
tag |
为入站命名。路由用 RouteMatch::InboundTag 匹配它,会话归属于它。 |
bind |
绑定的对象,也是监听器在多次应用之间的身份。绑定不变的入站保留它的 socket 或设备,背后的 handler(处理器)被切换。 |
sniff |
对按 IP 寻址的流,是否从中读出域名。构建器把它传给每一种 handler:流式入站、SOCKS 入站、Hysteria 2 的连接配置,以及 TUN 入站(为 false 时用 without_sniffing)。见嗅探。 |
protocol |
所服务的协议及其设置。见 InboundProtocolSpec。 |
users |
该入站准入的 UserSet 的 tag。每个用户都凭该协议所接受的那一种凭据准入,没有这种凭据的用户在此被跳过。None 表示协议的开放模式或共享模式。 |
user_removal |
为该入站用户的会话覆盖 Policies.user_removal。 |
topology/inbound/mod.rs 还定义了入站 spec 被构建成的类型:InboundHandler、StreamInbound、StreamProtocol、Ss2022Users、Hy2Handler 和 TunHandler。这些运行时类型见监听器与服务循环。
BindSpec
Section titled “BindSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub enum BindSpec { Tcp { host: String, port: u16 }, Udp { host: String, port: u16 }, Unix(PathBuf), Tun(TunSource),}
impl fmt::Display for BindSpec;| 变体 | Display |
服务 |
|---|---|---|
Tcp { host, port } |
host:port,例如 127.0.0.1:1080 |
SOCKS、HTTP、Trojan、VLESS、VMess、Shadowsocks、Shadowsocks 2022 |
Udp { host, port } |
udp host:port,例如 udp 0.0.0.0:8443 |
Hysteria 2 |
Unix(path) |
unix: 加路径,例如 unix:/run/etemenanki/proxy.sock |
与 Tcp 相同的流式协议,但没有传输层 |
Tun(TunSource::Create(spec)) |
tun 加接口名;spec.name 为 None 时为 tun auto |
TUN |
Tun(TunSource::Fd(device)) |
tun fd 加原始描述符编号 |
TUN |
有关绑定的错误打印的就是这个 Display 形式:
inbound b: 127.0.0.1:1080 is bound by another inboundinbound h2: udp 0.0.0.0:8443 is bound by another inboundinbound b: unix:/run/etemenanki/proxy.sock is bound by another inboundinbound t2: tun ete0 is bound by another inbound相等比较是派生的,所以两个绑定当且仅当每个字段都相等时才是同一个绑定:
- 同一端口上的 TCP 和 UDP 是不同的绑定。Hysteria 2 监听器可以与 SOCKS 监听器使用相同的端口号(
the_same_port_over_udp_is_another_bind)。 - 两个 TUN 入站如果都让内核选择接口名,并要求相同的 MTU、地址和路由,就是同一个绑定。etemenanki-app 以
inbound t2: tun auto is bound by another inbound拒绝这样的配置。
TUN 设备从哪里来
Section titled “TUN 设备从哪里来”#[derive(Debug, Clone, PartialEq, Eq)]pub enum TunSource { /// Created, addressed and routed by the inbound. Create(DeviceSpec), /// Handed over already configured. Fd(SuppliedTun),}
impl TunSource { pub fn mtu(&self) -> u16;}
#[derive(Debug, Clone)]pub struct SuppliedTun { pub fd: Arc<OwnedFd>, /// What the platform configured the interface with; at least 1280. pub mtu: u16,}
impl PartialEq for SuppliedTun { fn eq(&self, other: &Self) -> bool { self.fd.as_raw_fd() == other.fd.as_raw_fd() && self.mtu == other.mtu }}impl Eq for SuppliedTun {}#[derive(Debug, Clone, PartialEq, Eq)]pub struct DeviceSpec { /// Interface name; `None` lets the kernel pick one. pub name: Option<String>, pub mtu: u16, /// `(address, prefix length)` pairs assigned to the interface. pub addresses: Vec<(IpAddr, u8)>, /// `(network, prefix length)` pairs routed into the interface. pub routes: Vec<(IpAddr, u8)>,}Create。 由入站创建接口、分配addresses并安装routes(tun::open),在 Linux 上这需要CAP_NET_ADMIN。这些路由限定在该设备上,从不显式删除:设备的最后一个描述符关闭时,内核会移除接口及其路由。check_platform拒绝平台无法满足的设置;在 Linux 以外,routes非空会报tun routes are installed only on Linux; add them with the OS route tool。在 Android 和 iOS 上,tun::open拒绝所有设备:tun devices are created by the system VPN API here; adopt its descriptor。Fd。 移动平台 VPN API 提供的描述符。接口、地址和路由都归平台所有,所以check_platform不适用。入站在fd的复制描述符上提供服务(tun::adopt),停止时关闭这些副本;fd本身在最后一个持有者丢弃它时关闭,而 spec 就是持有者之一。adopt把它的副本设为非阻塞,而副本与原描述符共享文件状态标志,所以fd也变成非阻塞的。- 外部提供的设备如何比较。
OwnedFd没有相等比较,所以SuppliedTun比较原始描述符编号和 MTU。代码给出的理由:打开着的描述符的编号在它打开期间是唯一的,而两个值都让各自的描述符保持打开。因此,只有当 spec 指向同一个描述符时,一次应用才会保留该设备。 - MTU。
TunSource::mtu可读取任一来源的 MTU。两者都必须至少为MIN_TUN_MTU= 1280,即用户态协议栈支持的最小 MTU(inbound t: tun mtu must be at least 1280)。
修改 TUN 入站是一种中断性变更:计划为它生成一个 Disrupt 步骤,这次应用需要 ApplyOptions::allow_disruptive。etemenanki-app 拒绝 TUN 变更;重启即可应用。TUN 协议栈本身见 TUN。
InboundProtocolSpec
Section titled “InboundProtocolSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub enum InboundProtocolSpec { Socks { udp: bool, udp_bind: Option<IpAddr> }, Http { allow_transparent: bool, transport: InboundTransportSpec }, Trojan { transport: InboundTransportSpec }, Vless { transport: InboundTransportSpec }, Vmess { transport: InboundTransportSpec }, Shadowsocks { method: SsMethod, password: Secret<String> }, Ss2022 { method: Ss2022Method, psk: Secret<Vec<u8>> }, Hysteria2(Hysteria2InboundSpec), Tun(TunSpec),}每个变体对应一种绑定,也只凭一种凭据准入用户(build/users.rs → credential_kind):
| 变体 | 绑定 | 传输层 | 准入用户所凭 | users = None 表示 |
|---|---|---|---|---|
Socks |
Tcp 或 Unix |
无,普通流 | account |
不认证 |
Http |
Tcp 或 Unix |
InboundTransportSpec |
account |
没有账户:开放代理模式 |
Trojan |
Tcp 或 Unix |
InboundTransportSpec |
password |
拒绝:没有开放模式 |
Vless |
Tcp 或 Unix |
InboundTransportSpec |
uuid |
拒绝:没有开放模式 |
Vmess |
Tcp 或 Unix |
InboundTransportSpec |
uuid |
拒绝:没有开放模式 |
Shadowsocks |
Tcp 或 Unix |
无,普通流 | password |
只用服务端 password |
Ss2022 |
Tcp 或 Unix |
无,普通流 | ss2022_psk |
只用服务端 psk |
Hysteria2 |
Udp |
QUIC,自带 TLS | account;在 Hysteria2UserAuth::Password 下为 password |
只用 shared_password |
Tun |
Tun |
无 | 无:TUN 设备不准入用户 | 唯一的模式 |
协议放在错误种类的绑定上时,以 inbound <tag>: the protocol cannot be served on <bind> 拒绝(each_protocol_is_served_only_on_its_kind_of_bind)。没有用户集的 Trojan、VLESS 和 VMess 以 the protocol has no open mode and needs a user set 拒绝,因为没有人能连上(trojan_vless_and_vmess_need_a_user_set)。
Unix 监听器是本地的,所以不承载传输层。其上流式协议的 shape(StreamShape)必须是不带 TLS 的普通 StreamShape::Tcp(a unix socket carries no transport; its shape must be plain tcp),构建器也会忽略任何绑定在 Unix 上的入站的传输层。
SOCKS 与 HTTP
Section titled “SOCKS 与 HTTP”| 字段 | 含义 | 规则 |
|---|---|---|
Socks.udp |
是否提供 UDP ASSOCIATE。 |
无 |
Socks.udp_bind |
为 UDP 中继向客户端报告、并实际绑定的 IP。None 使用监听器的本地 IP。 |
Unix 监听器没有本地 IP,所以 Unix socket 上 udp = true 的 SOCKS 需要 udp_bind:socks over a unix socket has no local IP for UDP associate; set udp_bind or turn udp off。 |
Http.allow_transparent |
是否服务 origin-form 的请求目标,其目标取自 Host 头。 |
无 |
Trojan、VLESS 与 VMess
Section titled “Trojan、VLESS 与 VMess”这三种只有一个 transport 字段,并且总是需要用户集。Trojan 凭 password 准入,VLESS 和 VMess 凭 uuid。见 Trojan、VLESS 和 VMess:线格式与协议核心。
Shadowsocks 与 Shadowsocks 2022
Section titled “Shadowsocks 与 Shadowsocks 2022”Shadowsocks |
Ss2022 |
|
|---|---|---|
| 加密方法 | SsMethod:Aes128Gcm、Aes256Gcm、ChaCha20Poly1305、XChaCha20Poly1305 |
Ss2022Method:Blake3Aes128Gcm(2022-blake3-aes-128-gcm,16 字节密钥)、Blake3Aes256Gcm(2022-blake3-aes-256-gcm,32)、Blake3ChaCha20Poly1305(2022-blake3-chacha20-poly1305,32) |
| 服务端机密 | password,在用户之外额外准入 |
psk,已解码,恰好 method.key_len() 字节。入站不准入任何用户时,它就是唯一的会话密钥;一旦准入用户,它就是 Extended Identity Header 的身份 PSK(iPSK),每个用户自己的密钥才是其会话密钥 |
| 用户凭据 | password |
ss2022_psk,已解码,长度由每个准入它的入站决定 |
| 有用户时要求 | 任意方法 | AES-GCM 方法 |
Ss2022 的服务端密钥长度必须恰好等于方法的密钥长度,否则 spec 以 the shadowsocks-2022 key must be 32 bytes for this method 被拒绝(a_shadowsocks_2022_server_key_is_sized_to_its_method)。etemenanki-app 在构建 spec 之前解码服务端密钥(ss_2022::users::decode_psk:对去掉首尾空白的文本做标准 base64 解码,失败时报 decode PSK: <error>),再交给 ss_2022::users::normalise_psk。更长的密钥被折叠为方法的长度,保留其 SHA-256 的前 key_len 个字节(ss_2022::crypto::fold_key);更短的则被 app 拒绝:inbound ss: shadowsocks-2022: PSK too short (16 < 32)。
用户的 ss2022_psk 只做解码。每个准入该用户的入站把它规范化为自己的方法,因为长度由方法决定:validate_admission 检查它,user_table 折叠它。更长的密钥被折叠;更短的被拒绝,报错中写出用户而不写出密钥:inbound ss: user a@example.com: shadowsocks-2022: PSK too short (8 < 16)(a_shadowsocks_2022_user_key_must_normalise_to_the_method)。
Extended Identity Header 只为 AES-GCM 方法定义,所以使用 Blake3ChaCha20Poly1305 的 Ss2022 入站不能准入用户。protocols crate 在构建这样的用户表时拒绝它(protocols/src/ss_2022/users.rs → Validator::from_config),所以这次应用以构建错误失败:building inbound ss failed: shadowsocks-2022: multi-user requires an aes-gcm method。见 Shadowsocks AEAD 和 Shadowsocks 2022。
Hysteria2InboundSpec
Section titled “Hysteria2InboundSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct Hysteria2InboundSpec { pub tls: ServerTlsSpec, pub shared_password: Option<Secret<String>>, pub masquerade: Option<MasqueradeSpec>, pub obfs: Option<ObfsSpec>, pub udp_idle_timeout: Option<Duration>, pub max_connections: usize, pub max_circuits: usize, pub user_auth: Hysteria2UserAuth,}| 字段 | 含义 | 规则 |
|---|---|---|
tls |
证书链和私钥。QUIC 总是承载 TLS;没有明文的 Hysteria。 | 构建时由 protocols/src/hysteria/server/endpoint.rs → server_config 解析。 |
shared_password |
入站不准入用户集时,每个客户端出示的那一个密码。 | 它与 InboundSpec.users 恰好设置其中一个,且不能为空。 |
masquerade |
对未认证请求的应答,即伪装(masquerade)。None 时与 Go 的 http.NotFound 应答相同。 |
见下文 MasqueradeSpec。 |
obfs |
QUIC 之下的 Salamander 混淆。 | 密钥至少 4 字节。修改它是中断性变更。 |
udp_idle_timeout |
UDP 关联可以保持空闲多久。None 关闭 UDP 中继。 |
设置时须在 2 到 600 秒之间:udp_idle_timeout must be between 2 and 600 seconds。 |
max_connections |
同时服务的客户端连接数。 | 至少为 1:max_connections and max_circuits must be at least 1。 |
max_circuits |
整个监听器上存活的 circuit(被代理的 stream)数。 | 至少为 1,报错文本相同。 |
user_auth |
用户集凭哪种凭据准入。设置了 shared_password 时忽略。 |
见下文 Hysteria2UserAuth。 |
- 两者都设或都不设。 如果共享密码和用户集都设置了,同一个凭据可能有两种答案:
a shared password and a user set cannot both be set; a credential would have two answers。两者都不设:hysteria2 needs a shared password or a user set。共享密码为空:the shared password must not be empty。 - 空闲时长范围。
HY2_UDP_IDLE=2..=600秒,用Duration::as_secs检查,它会丢掉秒以下的小数部分:1.5 秒算作 1,被拒绝;600.9 秒算作 600,被接受。代码给出的这一范围的理由:低于下限,繁忙的关联会在两个包之间被清理掉;高于上限,已死的关联会占着 socket 长达十分钟。None不需要范围,因为它关闭了 UDP。 - 前端程序的默认值。
protocols/src/hysteria/server/config.rs中的DEFAULT_MAX_CONNECTIONS(262,144)和DEFAULT_MAX_CIRCUITS(4,194,304),katana 总是填入它们,etemenanki-app 在配置没有设置max_connections或max_circuits时填入它们。UDP 开启时,两个前端程序都把 UDP 空闲超时默认设为 60 秒,各自写的是字面量unwrap_or(60),而不是具名常量。spec 本身没有默认值。 - 前端程序的检查。 spec 无法表达“为关闭的中继设置超时”,所以两个前端程序都自行拒绝这种组合:
udp_idle_timeout is set but udp is not enabled。katana 的 lowering(src/lower/inbound.rs)还会先于 supervisor 检查 2 到 600 秒的范围,并用Masquerade::new构建伪装,所以违反其中任一条的节点配置,在 spec 生成之前就以相同的原因文本失败。 - 运行中监听器上的修改。 修改伪装会切换 handler,不中断任何人(
a_hysteria2_masquerade_change_swaps_without_disrupting)。修改obfs是一个Disrupt步骤,原因为the hysteria2 obfuscation changed; connected clients cannot follow the new key;不允许中断的应用会以inbound <tag>: the hysteria2 obfuscation changed; connected clients cannot follow the new key; this ends its live connections and needs allow_disruptive被拒绝。etemenanki-app 拒绝它,katana 允许它。修改max_circuits会给新 handler 一份全新的 circuit 配额;max_circuits不变时,新 handler 与运行中的监听器共用配额,所以存活的 circuit 继续计入其中(supervisor.rs→same_circuits)。见规划并应用变更。
监听器本身见 Hysteria 2:服务端。
Hysteria2UserAuth
Section titled “Hysteria2UserAuth”#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub enum Hysteria2UserAuth { /// Upstream's `user:pass`, from each user's `account`. #[default] Account, /// The whole auth string is the user's `password`. Password,}| 变体 | 凭据 | 准入规则 |
|---|---|---|
Account(默认) |
用户的 account,以 user:pass 形式出示 |
线上的用户名比较不区分大小写,所以仅大小写不同的两个账户会冲突:users <a> and <b> present the same username。线上格式按冒号拆分,所以含 : 的名字永远无法出示,而空的部分不算凭据:user <name>: a hysteria2 account needs a name without ':' and a password。 |
Password |
用户的 password,作为整个认证字符串 |
不能为空:user <name>: a hysteria2 password must not be empty。 |
etemenanki-app 总是降为 Account。katana 默认降为 Password,并把每个用户的 UUID 作为该密码。代码给出的理由:面板所面向的节点 agent 就是以 UUID 本身作为用户的 key,而面板的用户列表里没有供 user:pass 形式使用的密码字段。[node.hysteria] credential = "user_pass" 选择 Account;除 ""、"uuid" 和 "user_pass" 之外的值,katana 一律拒绝。
MasqueradeSpec
Section titled “MasqueradeSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct MasqueradeSpec { /// Never a success status: that would tell the prober it had /// authenticated. pub status: u16, pub body: String, pub content_type: String,}validate 用它构建一个 Masquerade(protocols/src/hysteria/server/masquerade.rs → Masquerade::new)来判断它能否接受,所以两种拒绝都来自 protocols crate:
hysteria2: 233 is the authentication success status and cannot be used for the masquerade,etemenanki-app 打印为inbound h1: hysteria2: 233 is the authentication success status and cannot be used for the masquerade;hysteria2: <status> is not an HTTP status code,针对 100 到 999 以外的状态码(这正是http::StatusCode::from_u16接受的范围):inbound h1: hysteria2: 99 is not an HTTP status code。
应答带有该状态码、值为 content_type 的 Content-Type,以及值为 body 长度的 Content-Length。None 构建 Masquerade::default():状态码 404,body 为 404 page not found\n,content type 为 text/plain; charset=utf-8,与未配置伪装的上游服务端的应答逐字节相同。配置只设置了部分伪装键时,两个前端程序都用这些相同的值填充其余部分。
TunSpec
Section titled “TunSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct TunSpec { /// Relay UDP flows as well as TCP connections. pub udp: bool, /// How long a UDP flow may stay quiet before it is retired. pub udp_idle_timeout: Duration, /// Flows tracked at once. pub max_flows: usize,}设备本身(包括 MTU)是入站的 BindSpec::Tun,所以 TunSpec 只保存协议栈如何使用它。build_handler 用 TunConfig { user: Principal::anonymous(), mtu: device.mtu(), udp, udp_idle_timeout, max_flows } 构建一个 TunInbound:TUN 流不携带凭据,所以每个流都是匿名的。配置省略的项由 etemenanki-app 用 protocols/src/tun/config.rs 的默认值填充,FFI 客户端自己添加的 TUN 入站也使用这些默认值:DEFAULT_MTU = 1500、DEFAULT_UDP_IDLE_TIMEOUT = 60 秒、DEFAULT_MAX_FLOWS = 65,536,并开启 udp。
ObfsSpec
Section titled “ObfsSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub enum ObfsSpec { Salamander { psk: Secret<Vec<u8>> },}Salamander 在 QUIC 之下把每个包与 BLAKE2b-256(psk || salt) 做异或。同一个类型服务两端:Hysteria2InboundSpec.obfs 和 Hysteria2OutboundSpec.obfs。两端的密钥都至少为 MIN_SALAMANDER_PSK = 4 字节(salamander obfs key must be at least 4 bytes,由 a_salamander_key_is_at_least_4_bytes_on_either_side 固定)。build/outbound.rs → obfs 把它转换成 protocols crate 的 Obfs::Salamander,监听器和 connector(连接器)都用它。线上格式见 Hysteria 2:协议与客户端。
StreamShape
Section titled “StreamShape”#[derive(Debug, Clone, PartialEq, Eq)]pub enum StreamShape { /// Plain TCP. Tcp, /// TLS over TCP: this project's own `network = "tls"`. Tls, Ws { path: String, /// The `Host` the config named, if any. Each side applies its own /// fallback: an outbound may borrow `server`, an inbound has nothing /// to borrow. host: Option<String>, tls: bool, }, Grpc { service: String, authority: Option<String>, tls: bool },}
impl StreamShape { /// Whether TLS is layered under this shape, and so whether the TLS /// material beside it must be present. pub fn uses_tls(&self) -> bool;}入站和出站共用这一个 shape。因此,关于某种网络需要哪些字段的规则,不会只落在其中一侧。在 etemenanki-app 中,app/src/transport.rs → resolve_stream 是从 [.stream] 块推导 shape 的唯一函数,两侧都用它。
uses_tls() 对 Tcp 为 false,对 Tls 为 true,对 Ws 和 Grpc 取其 tls 标志。每种 shape 被构建成什么:
| Shape | 入站(build/inbound.rs → build_transport) |
出站(build/outbound.rs → transport) |
ALPN |
|---|---|---|---|
Tcp |
InboundTransport::Tcp |
TransportKind::Tcp |
无 |
Tls |
InboundTransport::Tls |
TransportKind::Tls |
无 |
Ws |
InboundTransport::ws(path, host, tls) |
TransportKind::ws(host, path, tls) |
tls 时为 http/1.1 |
Grpc |
InboundTransport::grpc(service, tls) |
TransportKind::grpc(authority, service, tls) |
tls 时为 h2 |
host 和 authority 保存配置中指定的值(如果有的话),两侧各自应用自己的回退规则。入站的 WebSocket 监听器只在其 path 上接受升级,路径按 Xray 的方式规范化:去掉 ed= 查询参数(Xray 的 early-data 设置),空路径变为 /,不以 / 开头的路径补上 /(protocols/src/transports/ws/endpoint.rs → normalize_path)。设置了 host 时,它只接受 Host 匹配的请求,比较不区分大小写且忽略端口(host_matches);None 接受任意 Host。服务端不检查 gRPC 客户端发送的 authority,所以入站的 authority 没有意义。传输层本身见传输层:TCP 与 TLS 和传输层:WebSocket 与 gRPC。
InboundTransportSpec 与 ServerTlsSpec
Section titled “InboundTransportSpec 与 ServerTlsSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct InboundTransportSpec { pub shape: StreamShape, pub tls: Option<ServerTlsSpec>,}
/// What a server presents: a certificate chain and its private key, both PEM.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ServerTlsSpec { pub cert_pem: Vec<u8>, pub key_pem: Secret<Vec<u8>>,}tls当且仅当shape.uses_tls()成立时为Some。字段是公开的,所以应用时会双向检查:the transport layers tls but carries no tls settings,或the transport carries tls settings but does not layer tls(an_inbound_carries_tls_material_exactly_when_its_shape_layers_tls)。- 入站的 shape 可以有空缺:没有
host的 WebSocket shape 和没有authority的 gRPC shape 都有效(an_inbound_needs_no_ws_host_or_grpc_authority)。服务端没有可借用的来源,两者也都不需要。 - 校验从不解析 PEM;它的测试夹具用的是
b"cert"和b"key"。解析 PEM 的是构建器:流式传输层用TlsServerConfig::from_pem(cert, key, alpn),它使用 OpenSSL 的mozilla_intermediate_v5配置,最低 TLS 1.2,所以只支持 TLS 1.2 和 TLS 1.3;Hysteria 2 用hysteria::server::endpoint::server_config。无法解析的 PEM 是构建错误而不是校验错误:building inbound t failed: no certificate in PEM bundle,Hysteria 2 则是building inbound h1 failed: hysteria2: the certificate file contains no certificates。
OutboundTransportSpec、ClientTlsSpec 与 VerifyMode
Section titled “OutboundTransportSpec、ClientTlsSpec 与 VerifyMode”#[derive(Debug, Clone, PartialEq, Eq)]pub struct OutboundTransportSpec { pub shape: StreamShape, pub tls: Option<ClientTlsSpec>, /// Which family of the upstream's addresses is dialed. pub address_family: AddressFamilyStrategy,}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct ClientTlsSpec { /// The SNI sent, and the name the certificate is checked against. pub server_name: String, /// Which roots the certificate is verified against, if it is at all. pub verify: VerifyMode,}#[derive(Clone, Debug, Eq, PartialEq)]pub enum VerifyMode { /// Verify with the platform/OpenSSL default trust store. System, /// Verify with the platform/OpenSSL default trust store plus these PEM CAs. CustomCa(Vec<u8>), /// Disable certificate chain and hostname verification. Insecure,}与入站不同,出站的 shape 不能有空缺:WebSocket 的 host 和 gRPC 的 authority 必须是 Some。到达 supervisor 的空缺是前端程序的 bug,会被拒绝而不是被猜测填补:ws transport needs a host、grpc transport needs an authority(an_outbound_needs_a_ws_host_and_a_grpc_authority)。tls 当且仅当 shape 使用 TLS 时为 Some,报错文本与入站的两条相同(an_outbound_carries_tls_settings_exactly_when_its_shape_layers_tls)。构建器把 ClientTlsSpec 转换成 ClientConfig::with_verify_mode(server_name, verify, alpn),它设置最低 TLS 1.2。CustomCa 把证书包中的证书加入默认信任库,而不是替换它;证书包中没有证书时构建失败:building outbound p@v1 failed: no certificate in CA PEM bundle。Insecure 同时关闭证书链验证和主机名验证。
etemenanki-app 在 app/src/lower.rs → outbound_transport 中填补空缺:WebSocket host 依次取 ws.host、tls.server_name、server;gRPC authority 从 grpc.authority 开始,沿同样的链取值;SNI 依次取 tls.server_name、server。CA 文件以字节形式读入 VerifyMode::CustomCa,所以 spec 中从不出现它的文件名。
address_family 在上游服务器的地址中做选择,这些地址由传输层 connector 用 supervisor 的服务器解析器(Dns::servers)查询。
OutboundSpec 与 ProxyUpstream
Section titled “OutboundSpec 与 ProxyUpstream”#[derive(Debug, Clone, PartialEq, Eq)]pub struct OutboundSpec { pub tag: CompactString, pub protocol: OutboundProtocolSpec, /// Overrides Policies::drain for the live flows of this outbound once it /// is removed or changed. pub drain: Option<DrainPolicy>,}
/// Where a proxy client dials, and over what.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ProxyUpstream { /// The proxy server, always addressed over TCP. pub server: Destination, pub transport: OutboundTransportSpec,}#[derive(Debug, Clone, PartialEq, Eq, Hash)]pub struct Destination { pub network: DialNetwork, // Unknown = 0, Tcp = 1, Udp = 2, Unix = 3 pub remote: Remote, // IpAddr(IpAddr) or Domain(CompactString) pub port: u16,}路由和负载均衡器指定的是 tag。同一 tag 下 spec 有变化,就是该出站的一个新版本,即一个打印为 tag@vN 的 OutboundId;被替换版本上的流由排空策略处理(见规划并应用变更)。
ProxyUpstream.server 总是通过 TCP 拨号:无论 spec 怎么写,build/outbound.rs → tcp 都把它的 network 换成 DialNetwork::Tcp。etemenanki-app 在那里写的也是 DialNetwork::Tcp,所以它的 spec 能比较相等(见前端程序要为比较做到什么)。域名形式的远端在传输层拨号时解析。Destination 见 Link、connector 与网络类型。
OutboundProtocolSpec
Section titled “OutboundProtocolSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub enum OutboundProtocolSpec { Freedom { address_family: AddressFamilyStrategy }, Blackhole, Socks { upstream: ProxyUpstream, account: Option<Account> }, Http { upstream: ProxyUpstream, account: Option<Account> }, Trojan { upstream: ProxyUpstream, password: Secret<String> }, Vless { upstream: ProxyUpstream, id: Uuid }, Vmess { upstream: ProxyUpstream, id: Uuid, security: Security }, Shadowsocks { upstream: ProxyUpstream, method: SsMethod, password: Secret<String> }, Ss2022 { upstream: ProxyUpstream, method: Ss2022Method, identity_psks: Vec<Secret<Vec<u8>>>, psk: Secret<Vec<u8>>, }, Hysteria2(Hysteria2OutboundSpec), Wireguard(WireguardSpec),}| 变体 | 到达什么 | 负载均衡器成员(TCP 探测) | 持有的解析器 |
|---|---|---|---|
Freedom |
目的地本身,从本机出发;address_family 在目的地的地址中选择 |
否:没有上游 | Dns::destinations |
Blackhole |
什么都不到达:读取得到 EOF,数据包被吞掉 | 否:没有上游 | 无 |
Socks、Http |
upstream,可带一个 Account |
是:upstream.server |
Dns::servers |
Trojan |
upstream;密码在构建时做哈希(trojan::protocol::password_hash) |
是 | Dns::servers |
Vless、Vmess |
upstream;id 是用户的 UUID,VMess 还有 security(Aes128Gcm 或 ChaCha20Poly1305) |
是 | Dns::servers |
Shadowsocks |
upstream;密钥在构建时由密码派生(evp_bytes_to_key(password, method.key_len())) |
是 | Dns::servers |
Ss2022 |
upstream;identity_psks 按 iPSK:…:uPSK 的书写顺序排列,单用户服务器时为空;psk 是用户自己的密钥 |
是 | Dns::servers |
Hysteria2 |
它自己的 QUIC 连接,不是传输层 | 否:它使用 UDP | Dns::servers |
Wireguard |
它自己的隧道,不是传输层 | 否:它使用 UDP | 端点用 Dns::servers,隧道内用 Dns::destinations |
- 探测目标。 负载均衡器按健康状况选择成员,而健康状况是通过向上游发起 TCP 连接得知的。
build/validate.rs→probe_target为七个代理变体给出上游,其余四个则不给;WireGuard 和 Hysteria 2 使用 UDP,TCP 连接会让它们永远被标记为不可用。没有探测目标的成员会被拒绝:balancer pool: outbound direct has no upstream a TCP health probe can reach。 - 解析器。 除黑洞之外,每个出站都持有一个由
DnsSpec构建的解析器,所以DnsSpec变化时,除黑洞之外的所有出站都会重建(a_dns_change_rebuilds_every_outbound_but_a_blackhole)。 - Shadowsocks 2022 密钥。 每一把密钥,包括用户的和每一把身份密钥,都必须恰好是
method.key_len()字节:shadowsocks-2022 keys must be 16 bytes for this method(shadowsocks_2022_client_keys_are_sized_to_their_method)。与入站的用户密钥不同,supervisor 这一侧不会折叠它们。etemenanki-app 在降为 spec 时把配置的密码按:拆分(iPSK:…:uPSK),逐段解码并交给normalise_psk;最后一段成为psk,其余成为identity_psks。
每个变体如何变成出站 handler,包括它对 UDP 的支持,见出站、UDP 扇出与负载均衡器。
Hysteria2OutboundSpec
Section titled “Hysteria2OutboundSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct Hysteria2OutboundSpec { /// The server's UDP endpoint. pub server: Destination, /// The SNI sent, and the name the certificate is checked against. pub server_name: String, pub password: Secret<String>, pub verify: VerifyMode, pub obfs: Option<ObfsSpec>, /// Concurrent proxy streams on the connection. At least 1. pub max_concurrent_streams: usize, /// Which family of the server's addresses is dialed. pub address_family: AddressFamilyStrategy,}- 无论
network怎么写,server都通过 UDP 拨号(build/outbound.rs→udp),它的名字用Dns::servers查询,并按address_family过滤和排序。 password不能为空(hysteria2 password must not be empty),max_concurrent_streams至少为 1(max_concurrent_streams must be at least 1)。两者都由a_hysteria2_client_needs_a_password_and_a_stream固定。- 配置没有设置时,etemenanki-app 用
protocols/src/hysteria/config.rs的DEFAULT_MAX_CONCURRENT_STREAMS= 102,400 填充max_concurrent_streams,用出站的server填充server_name。 - spec 不变的出站通过
Arc连同它的 QUIC 连接一起沿用,所以应用之后打开的流走的是之前某个流打开的连接。spec 有变化的则是新版本,会自己拨一条新连接(an_unchanged_hysteria2_outbound_keeps_its_quic_connection_across_apply)。
WireguardSpec
Section titled “WireguardSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct WireguardSpec { pub private_key: Secret<[u8; 32]>, pub peer_public_key: [u8; 32], pub preshared_key: Option<Secret<[u8; 32]>>, pub endpoint: Destination, pub endpoint_address_family: AddressFamilyStrategy, pub addresses: Vec<IpAddr>, pub address_family: AddressFamilyStrategy, pub mtu: usize, pub keepalive: Option<u16>, pub reserved: Option<[u8; 3]>,}| 字段 | 含义 |
|---|---|
private_key、preshared_key |
本端私钥和可选的预共享密钥,各 32 字节,都是 Secret |
peer_public_key |
对端公钥,32 字节,不是机密 |
endpoint |
对端的 UDP 端点,与其他代理服务器一样:无论 network 怎么写,都通过 UDP 拨号 |
endpoint_address_family |
拨号时使用端点的哪个地址族;端点用 Dns::servers 查询 |
addresses |
隧道内的本地地址 |
address_family |
隧道内使用哪个地址族,针对用 Dns::destinations 查询的目的地 |
mtu |
隧道 MTU,在 IP 层。配置没有设置时,etemenanki-app 用 protocols/src/wireguard/config.rs 的 DEFAULT_MTU = 1420 填充 |
keepalive |
持久 keepalive 间隔,单位为秒 |
reserved |
Xray 的 3 字节 reserved 头字段 |
两个地址族字段回答的是不同的问题:endpoint_address_family 关乎经由主机网络到达对端,address_family 关乎在隧道内到达什么。address_family 只要求一个地址族(Ipv4Only 或 Ipv6Only)时,addresses 中必须有该族的地址,因为用户态协议栈没有别的源地址可用:wireguard address_family ipv6_only needs an IPv6 address(a_wireguard_address_family_needs_an_address_of_that_family)。prefer_* 策略和 Auto 没有这个要求。隧道见 WireGuard。
AddressFamilyStrategy
Section titled “AddressFamilyStrategy”#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]pub enum AddressFamilyStrategy { #[default] Auto, Ipv4Only, Ipv6Only, PreferIpv4, PreferIpv6,}| 变体 | as_str |
使用的地址 |
|---|---|---|
Auto(默认) |
auto |
全部,按解析器给出的顺序,该顺序已经体现了主机的地址选择规则 |
Ipv4Only |
ipv4_only |
只用 IPv4 |
Ipv6Only |
ipv6_only |
只用 IPv6 |
PreferIpv4 |
prefer_ipv4 |
全部,IPv4 在前(稳定排序,所以各族内部的顺序不变) |
PreferIpv6 |
prefer_ipv6 |
全部,IPv6 在前 |
它在 spec 中出现五次:Freedom.address_family、OutboundTransportSpec.address_family、Hysteria2OutboundSpec.address_family,以及 WireguardSpec.endpoint_address_family 和 address_family。应用它的拨号器见拨号器与 socket 策略。
前端程序用它的 FromStr 解析,该实现去掉首尾空白、转为小写,并把 - 当作 _:
| 接受的文本 | 变体 |
|---|---|
空、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 |
其他任何文本都以 unknown address family strategy 失败。
路由与负载均衡器
Section titled “路由与负载均衡器”RouteSpec 与 RouteRuleSpec
Section titled “RouteSpec 与 RouteRuleSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct RouteSpec { pub rules: Vec<RouteRuleSpec>, /// The outbound or balancer tag for a flow no rule matches. pub default: CompactString, /// The geoip `.dat` file `GeoIp` matchers look up. pub geoip: Option<PathBuf>, /// The geosite `.dat` file `GeoSite` matchers look up. pub geosite: Option<PathBuf>,}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct RouteRuleSpec { pub matchers: Vec<RouteMatch>, /// The outbound or balancer tag. pub outbound: CompactString,}- 首个匹配。 规则按顺序尝试。第一个有任一匹配条件命中的规则胜出,没有规则命中的流走
default。测试一条规则时对它的匹配条件使用Iterator::any:匹配条件之间是“或”的关系,而不是必须同时满足的条件。matchers为空的规则不匹配任何流。规则以它在rules中的索引标识(entity/id.rs→RuleId,一个u32),所以调整规则顺序会给它们重新编号。 - geo 数据。
geoip和geosite是指向.dat文件的路径,而不是字节。代码给出的理由:一个.dat文件动辄几 MB,而只需从中加载规则引用到的那些集合。build/route.rs→compile_routes收集每个GeoSite和GeoIp匹配条件的代码,连同这两个路径一起交给environment/src/routing.rs→build_geo_data,后者只在有匹配条件需要时才读取并解码文件。geo 代码和错误见下文。 - 按 tag 指定目标。
outbound和default指定一个出站或负载均衡器。编译后的路由表指向的是槽位而不是出站,每个不同的 tag 一个槽位,所以重建出站不必重新编译路由表(见 plane:为每个流选路)。
etemenanki-app 把每个 [[route.rule]] 降为一个 RouteRuleSpec,其中的匹配条件按固定的种类顺序排列:domain_suffix、domain_keyword、domain_full、domain_regex(整个列表合成一个集合)、cidr、source_cidr、inbound_tag、network、port、geosite、geoip。规则的每个键都进入这同一个列表,所以同时设置了 domain_suffix 和 port 的规则,会匹配满足其中任一条件的流,而不只是两者都满足的流。它把三种普通域名匹配条件转为小写。没有 [route].default 时,default 取第一个出站的 tag。
匹配条件指定的 geo 代码:
| 匹配条件 | 代码 | 含义 |
|---|---|---|
GeoSite |
code |
geosite 条目 code,比较不区分大小写 |
GeoSite |
code@attr |
条目 code 中只带属性 attr 的域名 |
GeoIp |
code |
geoip 条目 code,比较不区分大小写 |
GeoIp |
!code |
条目 code 以外的所有 IP 目的地 |
加载的集合以匹配条件的原始文本为 key,如 cn、google@ads 或 !cn。geo 匹配条件缺少对应文件、条目不存在,或文件无法解码,都会让路由构建失败:
a geosite matcher is used but no geosite file is configured,打印为building route failed: a geosite matcher is used but no geosite file is configured,geoip同理;geosite code not found: <code>和geoip code not found: <code>;- 文件不是预期的 protobuf 时,报
geosite decode: <error>和geoip decode: <error>。
RouteMatch
Section titled “RouteMatch”RouteMatch 属于 environment/src/routing.rs,即与 katana 共用的路由模型:
| 变体 | 流在何时匹配 |
|---|---|
GeoSite(code) |
它的域名在 geosite 集合 code 中 |
DomainSuffix(parent) |
它的域名是 parent 或其子域名:example.com 匹配 a.example.com,不匹配 notexample.com |
DomainKeyword(needle) |
它的域名包含 needle |
DomainFull(name) |
它的域名恰好是 name |
DomainRegex(DomainRegexSet) |
它的域名匹配该集合中的任一模式 |
GeoIp(code) |
它的目的地是 geoip 集合 code 中的 IP |
Cidr(IpCidr) |
它的目的地是该范围内的 IP |
SourceCidr(IpCidr) |
客户端自己的地址在该范围内 |
PortRange(lo, hi) |
它的目的端口在 lo 与 hi 之间,含两端 |
Network(TargetNetwork) |
它是 TCP 或 UDP |
InboundTag(tag) |
它从入站 tag 到达 |
域名匹配条件既尝试请求指定的域名,也尝试从载荷中嗅探出的域名,每个流各转一次小写。DomainSuffix、DomainKeyword 和 DomainFull 的值按原样比较,所以前端程序要把它们写成小写,etemenanki-app 就是这样做的。以 IP 给出的目的地不匹配任何域名匹配条件,除非嗅探到了域名;Cidr 和 GeoIp 从不匹配以域名给出的目的地。每个匹配条件读取路由器 RouteTarget(environment/src/routing.rs)中的一个字段:SourceCidr 读 source,Network 读 network,InboundTag 读 inbound_tag;路由器没有得到某个字段时,读取该字段的匹配条件不匹配。
DomainRegexSet 把一条规则的模式编译成一个 regex::RegexSet;一个集合会超出正则引擎的大小上限时,编译成多个(regexes_too_big_for_one_set_still_build)。DomainRegexSet::new 拒绝含无效模式的集合,并指出是哪个模式:invalid domain regex "(unclosed": regex parse error:,后面跟着 regex crate 自己的说明。RegexSet 没有相等比较,所以 DomainRegexSet 在源模式上定义相等:两条规则的模式写法相同、顺序相同时,才是同一条规则(domain_regex_equality_is_by_pattern)。匹配规则见路由模型。
BalancerSpec 与 Strategy
Section titled “BalancerSpec 与 Strategy”#[derive(Debug, Clone, PartialEq, Eq)]pub struct BalancerSpec { pub tag: CompactString, /// Outbound tags, in priority order. pub members: Vec<CompactString>, pub strategy: Strategy, /// How often each member is probed. pub probe_interval: Duration, /// How long one probe may take. pub probe_timeout: Duration,}pub const DEFAULT_PROBE_INTERVAL: Duration = Duration::from_secs(30);pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum Strategy { /// The first healthy member in configured order. Order is the priority. Failover, /// Each healthy member in turn. RoundRobin,}
impl Strategy { pub fn parse(s: &str) -> io::Result<Self>;}- 负载均衡器的 tag 与出站共用命名空间:路由指定负载均衡器的方式与指定出站完全相同。
members只能是出站 tag,按优先级排列。每个成员都必须存在且有 TCP 探测目标(见OutboundProtocolSpec),列表不能为空(balancer <tag> has no members)。Strategy::parse接受failover和round_robin,其他值一律以unknown balancer strategy "random" (expected "failover" or "round_robin")拒绝。etemenanki-app 会在前面加上负载均衡器:balancer pool: unknown balancer strategy "random" (expected "failover" or "round_robin")。配置没有指定策略时,它降为Failover。DEFAULT_PROBE_INTERVAL(30 秒)和DEFAULT_PROBE_TIMEOUT(5 秒)是前端程序在其来源没有指定间隔或超时时填入的值。spec 没有默认值。- 准备阶段按 spec 构建负载均衡器:为每个成员调用
Member::new(tag, target, probe),其中target是该成员构建好的出站,probe是它的probe_target,然后调用Balancer::new(members, strategy)。Balancer::new拒绝空列表(a balancer needs at least one outbound),而校验阶段早已以balancer <tag> has no members拒绝过它。
探测、选择,以及所有成员都不可用时会怎样,见出站、UDP 扇出与负载均衡器。
#[derive(Debug, Clone, PartialEq, Eq)]pub enum DnsSpec { /// One resolver for everything: the proxy servers the outbounds dial and /// the destinations this host reaches itself. Applications' DNS is /// routed like any other flow. Single(Backend), /// Resolution split by what is being looked up, with applications' DNS /// answered by the supervisor itself. Split { /// Asked directly. They look up the proxy servers, and the /// `through_proxy` servers' own names. pre_proxy: Vec<Backend>, /// Reached through the route table. They look up everything else, /// including every query applications send. Empty means the /// `pre_proxy` servers answer those too. through_proxy: Vec<Backend>, /// Answer from the system hosts file before asking any server. use_hosts: bool, /// Whether `AAAA` answers are returned to applications. resolve_ipv6: bool, },}#[derive(Debug, Clone, PartialEq, Eq)]pub enum Backend { /// The host resolver (getaddrinfo). The default. System, Udp(ServerAddr), Tls { server: ServerAddr, server_name: CompactString, ca_pem: Option<Vec<u8>> }, Https { server: ServerAddr, host: CompactString, path: CompactString, ca_pem: Option<Vec<u8>> },}
#[derive(Debug, Clone, PartialEq, Eq)]pub enum ServerAddr { Ip(SocketAddr), Name { host: CompactString, port: u16 },}build/dns.rs → build_dns 把 DnsSpec 变成两个解析器,对 Split 还有一个 DNS 服务:
| 查询对象 | Single(backend) |
Split |
|---|---|---|
出站拨号的代理服务器(Dns::servers) |
backend |
pre_proxy 服务器,直接询问 |
本进程自己到达的目的地,经由 freedom 以及在 WireGuard 隧道内(Dns::destinations) |
同一个解析器,所以各出站共用一份缓存 | through_proxy 服务器,经路由表到达;through_proxy 为空时用 pre_proxy 服务器 |
| 应用程序自己的 DNS 查询 | 与其他流一样被路由 | 由 supervisor 的 DNS 服务用上一行的 Dns::destinations 解析器应答;仅当 resolve_ipv6 时返回 AAAA 应答 |
| 系统 hosts 文件 | 仅由 Backend::System 通过 getaddrinfo 读取;服务器后端得不到 hosts 条目 |
use_hosts 时由 supervisor 读取,两个解析器都优先用它应答 |
在 Single 中,Backend::System 构建 Resolver::system(),其他后端构建一个 Resolver::with_upstreams,其 ResolverOptions 只设置了 socket,即 supervisor 的 socket 策略:没有 hosts 条目,没有自己的拨号器,也没有 bootstrap。Split 用它的列表构建 with_upstreams 解析器,其中 through_proxy 解析器经由 plane 拨号,并以 pre_proxy 解析器作为 bootstrap。设置了 use_hosts 时,build_dns 读取系统 hosts 文件(Hosts::system())并交给两个解析器;读取失败会让构建失败,报 building dns failed: <error>。以地址指定的服务器是一个 ServerAddr:ServerAddr::new(host, port) 把 IP 字面量变成 Ip,其他一切变成 Name。DNS-over-TLS 或 DNS-over-HTTPS 服务器的 CA 以字节形式携带(ca_pem),与 spec 中的其他证书一样。etemenanki-app 把 [dns] 降为 Single(a_dns_backend_needs_what_it_names),把订阅文件的 [dns] 降为 Split(a_subscribe_file_splits_dns)。解析和 DNS 服务见名称解析与 DNS 服务和 DNS 解析器。
UserSet<U> 与 UserSpec
Section titled “UserSet<U> 与 UserSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub struct UserSet<U: UserId> { pub tag: CompactString, pub users: BTreeMap<U, UserSpec>,}
/// One user: every credential they may present. Who they are is their key/// in the UserSet.#[derive(Debug, Clone, PartialEq, Eq)]pub struct UserSpec { pub credentials: Credentials, pub speed_limit: Option<std::num::NonZeroU64>,}用户是独立的资源。在服务端,用户变动远比其他任何东西频繁,一个面板也可能管着大量用户,所以用户不属于任何入站:入站按 tag 指定一个用户集,替换用户集不必调和监听器、出站或路由。用户集以 U 为 key,所以修改一个用户就是修改一个条目,两个用户集可以逐条比较。
speed_limit 是每秒的载荷字节数,由该用户的所有流共享,双向、TCP 和 UDP 都算在内。令牌桶允许一秒的突发;一次传输在完成之后按全额扣除,欠额还清之前该用户的其他传输都不能进行。None 表示不限速,零无法写出。修改限速会保留用户的会话,因为用户是按凭据准入的,而凭据没有变(a_speed_limit_change_keeps_the_users_sessions)。同一个 U 出现在两个用户集中,对 supervisor 来说是同一个用户,适用各用户集给出的最小限速;一个用户集中的 None 不会解除另一个用户集给出的限速(supervisor.rs → publish_speed_limits)。限速只在提交时发布,无论是应用的提交还是用户编辑的提交,所以被拒绝的变更不会改动当前生效的限速。限速的执行见流跟踪、统计与限速。
Credentials、Credential 与 Account
Section titled “Credentials、Credential 与 Account”#[derive(Debug, Clone, Default, PartialEq, Eq)]pub struct Credentials { /// For VLESS and VMess. pub uuid: Option<Uuid>, /// For Trojan and Shadowsocks. pub password: Option<Secret<String>>, /// For Shadowsocks 2022: the user's PSK, decoded. pub ss2022_psk: Option<Secret<Vec<u8>>>, /// For SOCKS, HTTP and Hysteria 2. pub account: Option<Account>,}
impl Credentials { pub fn get(&self, kind: CredentialKind) -> Option<Credential>;}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub enum CredentialKind { Uuid, Password, Ss2022Psk, Account }
#[derive(Debug, Clone, PartialEq, Eq)]pub enum Credential { Uuid(Uuid), Password(Secret<String>), Ss2022Psk(Secret<Vec<u8>>), Account(Account),}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct Account { pub user: CompactString, pub pass: Secret<String>,}一个用户每种凭据至多一个,入站只读取其协议所接受的那一种,所以同一个用户可以被不同协议的入站准入。Credentials::get(kind) 把那一个凭据作为 Credential 返回,准入和用户表处理的都是这种形式。
| 入站协议 | CredentialKind |
读取字段 |
|---|---|---|
| SOCKS、HTTP | Account |
account |
使用 Hysteria2UserAuth::Account 的 Hysteria 2 |
Account |
account |
使用 Hysteria2UserAuth::Password 的 Hysteria 2 |
Password |
password |
| Trojan、Shadowsocks | Password |
password |
| VLESS、VMess | Uuid |
uuid |
| Shadowsocks 2022 | Ss2022Psk |
ss2022_psk |
| TUN | 无 | 无:TUN 设备不准入用户 |
在同一个入站内,任何两个用户都不能出示该入站所读取种类的同一个凭据:users <a> and <b> present the same credential,账户则为 … the same username。其他种类的凭据可以相同(a_shared_credential_of_another_kind_is_no_conflict)。准入、principal,以及用户凭据变化时其会话会怎样,见用户、principal 与会话。
UserId 与 UserName
Section titled “UserId 与 UserName”#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]pub enum UserName { Email(CompactString), Username(CompactString),}
impl UserName { pub fn as_str(&self) -> &str;}
impl fmt::Display for UserName; // the bare name, without the variant
pub trait UserId: Clone + Eq + Ord + Hash + Debug + Send + Sync + 'static { /// The name nodes surface for this user. fn name(&self) -> UserName;}
impl UserId for UserName { fn name(&self) -> UserName { self.clone() }}UserName是节点称呼用户所用的名字:用户配置的email,或账户的用户名。它从来不是机密,所以日志和各协议自己的用户标签带的都是它,准入错误打印的也是它。派生的Ord把所有Email排在所有Username之前。UserId是前端程序给用户建索引所用的 key:它本来怎么称呼用户,就用什么。它是UserSet的 key,也是UsageDelta的主体。好的UserId有两个特性。一是它不是凭据,因为日志、协议标签和用量报告带的都是它的name()。二是用户凭据变化时它保持不变,这样改密码就是编辑一个条目,而不是移除一个用户再添加另一个。代码文档的说法是它与用户的认证信息挂钩,要么由认证信息派生,要么派生出认证信息:etemenanki-app 以 email 或账户名为用户建索引,直接使用UserName;katana 以面板的数字 id 为用户建索引,Uid::name返回以十进制 id 为值的UserName::Username。- 在 supervisor 内部,每个用户由 supervisor 自己的
entity::id::UserKey标识,这是一个u64newtype,连接携带的是它而不是U。它与 etemenanki-app 的lower::UserKey无关。见用户、principal 与会话和按用户的用量计费。
etemenanki-app 以 email 为 Trojan、Shadowsocks、Shadowsocks 2022、VLESS 或 VMess 用户建索引,以用户名为 SOCKS 或 HTTP 账户建索引;Hysteria 2 用户有 email 时用 email,否则用用户名(users_are_keyed_by_the_name_the_config_gives_them)。因为 key 是名字而从不是凭据,密码变化的用户仍是用户集中的同一个条目,只是凭据换了,而不是移除一个用户再添加另一个(a_user_keeps_its_key_when_its_password_changes)。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub enum UserRemovalPolicy { Keep, #[default] Close, CloseAfter(Duration),}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub enum DrainPolicy { #[default] Keep, Close, CloseAfter(Duration),}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub struct Policies { pub user_removal: UserRemovalPolicy, pub drain: DrainPolicy,}| 策略 | 适用于 | 默认值及原因 | 覆盖 | 由谁确定 |
|---|---|---|---|---|
UserRemovalPolicy |
用户被移出用户集或其凭据变化时,该用户的存活会话 | Close:撤销访问就是要让它生效 |
InboundSpec.user_removal |
supervisor.rs → removal_policy:取入站的覆盖值,否则取 Policies.user_removal |
DrainPolicy |
被移除、或被新版本替换的出站版本上的存活流 | Keep:TCP 流无法在中途迁移到后继版本,关闭它只会把一次配置变更变成连接中断 |
OutboundSpec.drain |
topology/spec_plan/plan.rs → plan(其中的 drain_policy 闭包):取期望 spec 中同 tag 出站的覆盖值,该 tag 已被移除时取旧 spec 中的覆盖值;否则取 Policies.drain |
每个变体让移除或排空做什么:
| 变体 | UserRemovalPolicy |
DrainPolicy |
|---|---|---|
Keep |
不关闭该用户的任何存活会话 | 不关闭旧版本的任何流;新流走后继版本 |
Close |
立即关闭它们 | 立即关闭它们(drain → Target::close_flows) |
CloseAfter(grace) |
关闭 grace 之后仍打开的会话 |
关闭 grace 之后仍打开的流,由 supervisor 的 task tracker 上的一个任务执行,关停时根 token 会取消它 |
- 被移除的出站按旧 spec 中它自己的覆盖值排空(
a_removed_outbound_drains_under_its_own_policy)。有变化的出站按期望 spec 中的覆盖值或默认值排空其旧版本(a_drain_takes_the_supervisor_default_unless_the_outbound_overrides_it)。 plan不比较Spec.policies,所以只改默认值的 spec 什么都不构建。应用用期望 spec 的默认值确定排空和移除策略,用户编辑用运行中 spec 的默认值,所以新的默认值从带来它们的那次应用起生效,并适用于之后的每次应用和用户编辑。- 覆盖值是
InboundSpec和OutboundSpec的字段,所以修改覆盖值就是修改那个入站或出站:入站的 handler 被重建并切换,出站被重建为新版本。 - 两个前端程序都不设置策略。etemenanki-app 和 katana 都降为不带覆盖的
Policies::default(),只有自己构建 spec 的嵌入方才会修改策略。
排空和移除如何执行,见规划并应用变更和用户、principal 与会话。
supervisor 为什么比较 spec
Section titled “supervisor 为什么比较 spec”supervisor 把最近一次应用的 spec 保存为 RunningState.spec。一次应用比较的不是两份配置的差异,而是两份 spec。因为每个值都带类型,两份 spec 之间的差异就是含义上的差异,而不会是写法上的差异;规划器无须知道两份 spec 各由哪个前端程序写出,就能决定保留什么。plan 逐个资源比较,而不是把 spec 作为整体比较:
| 资源 | 跨应用匹配依据 | 比较内容 | 不相等时 |
|---|---|---|---|
| DNS | 只有一个 | 整个 DnsSpec |
Build(Dns),除黑洞外的每个出站也会重建 |
| 出站 | tag |
整个 OutboundSpec,包括 drain |
Build 下一个版本;旧版本得到一个 Drain 步骤 |
| 负载均衡器 | tag |
整个 BalancerSpec,以及是否有成员被重建 |
Build;健康状况重新探测得知(a_balancer_is_rebuilt_exactly_when_one_of_its_members_is) |
| 路由 | 只有一个 | 整个 RouteSpec |
Build(Route),其他都不重建(a_route_change_alone_rebuilds_only_the_route_table) |
| 用户集 | tag |
整个 UserSet |
Build(UserSet),该次应用把它列在 ApplyReport.built 中。准备阶段不针对这个步骤做任何事:它重新计算每个入站的准入结果,对准入的用户、principal 或凭据有变化的每个运行中入站,重建其用户表(supervisor.rs → same_admissions)。如果用户集的变化让这些都保持不变,例如修改 speed_limit,或变动的用户没有该入站读取的凭据种类,就不会重建任何用户表。 |
| 入站 | bind |
整个 InboundSpec,包括 tag |
同一绑定上:Build 和 SwapHandler。新绑定上:Bind 和 Build。以下情况额外加一个 Disrupt:同一绑定上有变化的 TUN 入站;新绑定上的 TUN 入站,且其 tag 之前指的就是一个 TUN 入站(设备变了);Hysteria 2 的 obfs 变化。 |
| 策略 | 不比较 | — | 在确定排空和移除策略时读取 |
运行中的入站,如果没有任何期望入站带着它的 tag,就得到 CloseSessions;所以在同一绑定上重命名入站,会切换它的 handler,并关闭旧 tag 的会话(an_inbound_renamed_on_the_same_bind_swaps_and_closes_the_old_tags_sessions),见规划并应用变更。任何重建的 DNS、出站、负载均衡器或路由,或者被移除的出站或负载均衡器,都会加上一个 PublishPlane(removing_a_balancer_alone_republishes_the_plane)。没有变化的 spec 只规划 Reuse 步骤,什么都不发布(an_unchanged_spec_reuses_everything_and_publishes_nothing)。完整的规划规则见规划并应用变更。
两个值何时相等
Section titled “两个值何时相等”相等是结构性的:每个 spec 类型都派生 PartialEq 和 Eq,只有两个类型的相等比较是手写的。下表也列出了 Secret:它的相等是派生的,所以机密值变了就是 spec 变了,它隐藏的只是 Debug 输出。
| 类型 | 实现 | 何时相等 | 原因 | 固定它的测试 |
|---|---|---|---|---|
Secret<T> |
派生 | 内容相等 | 机密值变了必须就是 spec 变了 | a_user_keeps_its_key_when_its_password_changes,它断言用户的条目不同 |
SuppliedTun |
手写 | 原始描述符编号相同且 mtu 相同 |
OwnedFd 没有相等比较,而打开着的描述符的编号在其打开期间唯一 |
无 |
DomainRegexSet |
手写 | 源模式相同且顺序相同 | RegexSet 没有相等比较;一条规则就是它写出来的样子 |
domain_regex_equality_is_by_pattern |
类型化在比较之前就消除了写法上的差异:UUID 无论大写还是小写都解析为同一个 Uuid,base64 密钥解码为同样的字节,方法名变成同一个枚举值,etemenanki-app 还会把路由域名转为小写(在 a_server_config_lowers_to_its_spec 中,Example.COM 变为 DomainSuffix("example.com"))。
哪些列表比较时看顺序:
| 列表 | plan 是否比较顺序 | 原因 |
|---|---|---|
Spec.inbounds、outbounds、balancers、user_sets |
否 | 每一项按其绑定或 tag 查找 |
UserSet.users |
否 | 它是 BTreeMap |
RouteSpec.rules |
是 | 首个匹配胜出 |
RouteRuleSpec.matchers |
是,尽管匹配结果与顺序无关 | Vec 的相等比较 |
BalancerSpec.members |
是 | 优先级顺序 |
DnsSpec::Split 的服务器列表 |
是 | Vec 的相等比较 |
Ss2022.identity_psks |
是 | 身份链的顺序 |
WireguardSpec.addresses、DeviceSpec.addresses 和 routes |
是 | Vec 的相等比较 |
前端程序要为比较做到什么
Section titled “前端程序要为比较做到什么”- 确定性。 同一输入降为 spec 两次,必须得到相等的 spec(
lowering_twice_gives_the_same_spec),否则每次应用都会重建没有变化的东西。spec 中的随机值、时间戳或HashMap的迭代顺序都会让规划器失效。 - 构建器忽略的字段也要保持一致。 构建器把
ProxyUpstream.server.network改写为 TCP,把Hysteria2OutboundSpec.server.network和WireguardSpec.endpoint.network改写为 UDP。仅在这些地方不同的 spec 也是不相等的,于是出站会被重建,尽管它实际上什么都没变。etemenanki-app 总是写入构建器实际使用的网络(semantic_problems_are_left_to_the_supervisor对 WireGuard 端点和 Hysteria 2 服务器都断言为DialNetwork::Udp)。 - 证书和私钥的字节。 前端程序自己读取证书、私钥和 CA 证书包,把它们的字节放进 spec:
ServerTlsSpec、VerifyMode::CustomCa和 DNS 后端的ca_pem。 - 按名字引用用户。 入站按 tag 指定用户集,所以用户的变化不会让任何
InboundSpec变得不相等,既不触及 plane,也不触及监听器(a_user_set_change_alone_touches_neither_the_plane_nor_the_inbounds)。
| 不变量 | 由谁保证 | 固定它的测试 |
|---|---|---|
两份 spec 用 == 逐个资源比较 |
派生的相等比较,加上 SuppliedTun 和 DomainRegexSet 的手写实现 |
an_unchanged_spec_reuses_everything_and_publishes_nothing、lowering_twice_gives_the_same_spec |
包在 Secret 中的值永远不会进入 Debug 输出 |
Secret 手写的 Debug |
a_spec_never_prints_a_credential |
| tag 在各自的命名空间内唯一;出站和负载均衡器共用一个命名空间 | validate → unique_tags 和冲突检查 |
tags_are_unique_within_each_kind、a_balancer_may_not_share_an_outbounds_tag、an_inbound_may_share_an_outbounds_tag |
| spec 中没有空 tag | unique_tags |
no_tag_may_be_empty |
| 每个 tag 引用都能解析,负载均衡器成员是出站 | validate |
a_route_rule_must_name_a_known_outbound_or_balancer、the_route_default_must_name_a_known_outbound_or_balancer、a_balancer_member_must_be_a_known_outbound、a_balancer_member_may_not_be_another_balancer、an_inbound_must_name_a_known_user_set |
| 任何两个入站都不共用一个绑定 | validate |
two_inbounds_may_not_share_a_bind、the_same_port_over_udp_is_another_bind |
| 每个协议只在其种类的绑定上服务 | validate_inbound |
each_protocol_is_served_only_on_its_kind_of_bind |
| 在两侧,TLS 材料都恰好在 shape 叠加 TLS 时出现 | validate_inbound、outbound_transport |
an_inbound_carries_tls_material_exactly_when_its_shape_layers_tls、an_outbound_carries_tls_settings_exactly_when_its_shape_layers_tls |
| 出站的 shape 没有空缺;入站的可以有 | outbound_transport |
an_outbound_needs_a_ws_host_and_a_grpc_authority、an_inbound_needs_no_ws_host_or_grpc_authority |
| Unix 监听器只承载普通 TCP | validate_inbound |
a_unix_listener_carries_only_the_plain_tcp_shape |
| Trojan、VLESS 和 VMess 总是准入一个用户集 | validate_inbound |
trojan_vless_and_vmess_need_a_user_set |
| Hysteria 2 入站在共享密码和用户集之中恰好有一个 | validate_inbound |
hysteria2_takes_a_shared_password_or_a_user_set_but_not_both |
| 在一个入站内,一个凭据只有一个所有者 | validate_admission |
two_users_may_not_present_the_same_credential、hysteria2_usernames_collide_case_insensitively |
| 用户以名字为 key,从不以凭据为 key | etemenanki-app 的 InlineUsers |
users_are_keyed_by_the_name_the_config_gives_them、a_user_keeps_its_key_when_its_password_changes |
| etemenanki-app 的 lowering 是忠实的:它把 supervisor 的规则留给 supervisor | app/src/lower.rs → lower |
semantic_problems_are_left_to_the_supervisor |
| 违反规则的 spec 在规划任何东西之前就被拒绝,且什么都不改变 | plan 先调用 validate |
an_invalid_spec_is_refused_without_a_plan、a_refused_spec_changes_nothing |
| 没有变化的出站连同其连接一起沿用 | plan 生成 Reuse |
an_unchanged_hysteria2_outbound_keeps_its_quic_connection_across_apply |
失败路径与取消
Section titled “失败路径与取消”spec 本身不会失败:它是字段公开的数据。围绕它的失败发生在以下这些地方。
| 位置 | 什么会失败 | 文本 |
|---|---|---|
| 前端程序中的 lowering | 解析、读取文件、spec 无法表达的内容,以及少数会校验输入的 spec 辅助函数 | DomainRegexSet::new:invalid domain regex "<pattern>": <regex error>,或者没有单个模式出错时为 domain regex set of <n> patterns: <error>。Strategy::parse:unknown balancer strategy "<s>" (expected "failover" or "round_robin")。normalise_psk:shadowsocks-2022: PSK too short (<n> < <k>)。etemenanki-app 自己的拒绝包括 config defines no outbounds、<ctx>: udp_idle_timeout is set but udp is not enabled 和 <ctx>: unknown obfs "<obfs>" (expected "salamander")。前端程序把这些作为自己的错误报告。 |
| 校验 | 类型无法承载的规则 | 一个 ApplyError:duplicate <kind> tag <tag>、<from> references unknown <kind> <tag>、balancer <tag> has no members、balancer <tag>: outbound <member> has no upstream a TCP health probe can reach,或 Invalid 的 <resource>: <reason> |
| 规划之后的中断检查 | 含 Disrupt 步骤的计划在没有 allow_disruptive 的情况下应用 |
ApplyError::Disruptive:inbound <tag>: <reason>; this ends its live connections and needs allow_disruptive |
| 准备阶段的构建 | 从有效的 spec 构造时失败:PEM 无法解析、geo 数据无法读取、CA 无法加载、hosts 文件无法读取 | ApplyError::Build:building <resource> failed: <source>,例如 building inbound t failed: no certificate in PEM bundle |
| 准备阶段的绑定 | 新绑定的监听器无法打开 | ApplyError::Bind:inbound <tag>: binding <bind> failed: <source>,其中的绑定以 BindSpec 的 Display 形式打印 |
| actor | supervisor 已关停,或用户编辑指定的用户集不存在 | ApplyError::Stopped:the supervisor has shut down;user set <tag>: no such user set |
validate 按固定顺序检查,第一个失败就是返回的错误:用户集 tag、出站 tag、负载均衡器 tag、与某个出站相同的负载均衡器 tag、入站 tag;然后逐个检查出站;然后逐个检查负载均衡器(非空、每个成员都是出站、每个成员都可探测);然后是每条路由规则和路由默认目标;最后依次检查每个入站:它的绑定是否与之前的绑定冲突、它的用户集是否存在、它自己的字段,以及它的用户集的准入。逐条规则见校验与应用错误。
对少数校验会拒绝的 spec,构建器也会把它们变成自己的错误而不是 panic:build/outbound.rs → transport 中的 missing tls、missing ws host 和 missing grpc authority,以及 build/inbound.rs 中的 missing tls 和 inbound <tag>: protocol and bind do not match。spec 只有通过 validate 之后才会到达构建器,所以经由 apply、update 或 check 不会遇到这些错误。
Invalid 和 Build 用 entity/id.rs → Resource 的 Display 打印资源:
Resource |
打印为 |
|---|---|
Inbound(tag) |
inbound <tag> |
Outbound(id) |
outbound <tag>@v<version> |
Balancer(tag) |
balancer <tag> |
UserSet(tag) |
user set <tag> |
Route |
route |
Dns |
dns |
DuplicateTag 和 UnknownReference 用 ResourceKind 的 Display 打印种类:inbound、outbound、balancer 或 user set。
被拒绝的 spec 被整体拒绝,运行状态原样继续运行(a_refused_spec_changes_nothing)。这对 update 同样成立:actor 编辑的是副本,所以结果被拒绝的闭包不会改变 RunningState.spec。被拒绝的用户编辑不存入任何用户表,运行中的 spec 保持原样。etemenanki-app 在 --test 下把这些错误打印在 configuration invalid: 之后,第一份 spec 被拒绝时则打印在 failed to start: 之后。完整的规则和文本列表见校验与应用错误。
spec 不派生任何任务,也不持有任务,所以没有什么需要取消。丢弃 spec 会释放内存;如果 spec 持有外部提供的 TUN 描述符的最后一个引用,还会关闭该描述符。
supervisor 对 spec 施加的界限:
| 常量或规则 | 值 | 定义位置 | 适用于 |
|---|---|---|---|
MIN_TUN_MTU |
1280 | build/validate.rs |
TunSource::mtu(),两种来源 |
HY2_UDP_IDLE |
2 到 600 秒,含两端 | build/validate.rs |
设置时的 Hysteria2InboundSpec.udp_idle_timeout |
MIN_SALAMANDER_PSK |
4 字节 | build/validate.rs |
ObfsSpec::Salamander.psk,两端都适用 |
| Shadowsocks 2022 密钥长度 | Blake3Aes128Gcm 为 16 字节,另外两种方法为 32 |
protocols/src/ss_2022/crypto.rs → Method::key_len |
服务端 psk 和每把出站密钥必须恰好等于它;用户的 ss2022_psk 至少为它,更长的会被折叠 |
| 数量 | 至少为 1 | build/validate.rs |
max_connections、max_circuits、max_concurrent_streams |
| 伪装状态码 | 100 到 999,233 除外 | protocols/src/hysteria/server/masquerade.rs → Masquerade::new |
MasqueradeSpec.status |
| 有用户的 Shadowsocks 2022 | AES-GCM 方法 | protocols/src/ss_2022/users.rs → Validator::from_config,构建时 |
入站准入用户时的 InboundProtocolSpec::Ss2022.method |
| 密钥大小 | 32 字节 | 类型 | WireGuard 私钥、公钥和预共享密钥 |
reserved |
3 字节 | 类型 | WireguardSpec.reserved |
| 限速 | 至少 1 字节/秒 | NonZeroU64 |
UserSpec.speed_limit |
前端程序填入的默认值。除了 Policies、Credentials、Hysteria2UserAuth 和 AddressFamilyStrategy 的 Default 之外,spec 自己没有默认值:
| 常量 | 值 | 定义位置 | 字段 |
|---|---|---|---|
DEFAULT_MAX_CONNECTIONS |
262,144 | protocols/src/hysteria/server/config.rs |
Hysteria2InboundSpec.max_connections |
DEFAULT_MAX_CIRCUITS |
4,194,304 | protocols/src/hysteria/server/config.rs |
Hysteria2InboundSpec.max_circuits |
DEFAULT_MAX_CONCURRENT_STREAMS |
102,400 | protocols/src/hysteria/config.rs |
Hysteria2OutboundSpec.max_concurrent_streams |
tun::DEFAULT_MTU |
1500 | protocols/src/tun/config.rs |
DeviceSpec.mtu、SuppliedTun.mtu |
DEFAULT_UDP_IDLE_TIMEOUT |
60 秒 | protocols/src/tun/config.rs |
TunSpec.udp_idle_timeout |
DEFAULT_MAX_FLOWS |
65,536 | protocols/src/tun/config.rs |
TunSpec.max_flows |
wireguard::DEFAULT_MTU |
1420 | protocols/src/wireguard/config.rs |
WireguardSpec.mtu |
DEFAULT_PROBE_INTERVAL |
30 秒 | supervisor/src/topology/balancer.rs |
BalancerSpec.probe_interval |
DEFAULT_PROBE_TIMEOUT |
5 秒 | supervisor/src/topology/balancer.rs |
BalancerSpec.probe_timeout |
运行时适用的上限(存活连接、握手、缓冲区)见监听器与服务循环及其链接的页面。
修改 spec
Section titled “修改 spec”新字段、新变体或新协议首先要改的是 spec。spec 这一侧的步骤:
- 用
#[derive(Debug, Clone, PartialEq, Eq)]添加字段或变体。给值定类型:用枚举而不是字符串,用解码后的字节而不是文本,用字节而不是路径。机密值放在Secret中。没有Eq的类型需要手写PartialEq,说明“相同”是什么意思,就像DomainRegexSet和SuppliedTun那样。 - 把关于该值如何与 spec 其余部分配合的每条规则都放进
build/validate.rs,并在supervisor/tests/unit/validate.rs中加一个测试:在其余部分有效的spec()夹具中恰好违反这条规则,再加上仍被接受的边界情况。前端程序只检查它自己的格式能表达、而 spec 不能表达的内容。 - 在对应的构建器中使用该值:
build/inbound.rs、build/outbound.rs、build/dns.rs或build/route.rs。对 spec 枚举的 match 大多是穷尽的,所以编译器会列出需要扩展的地方。有两处带有兜底分支,新变体会悄无声息地落入其中;凭据种类则横跨好几个文件:build/validate.rs→validate_outbound用_ => None选出要检查的传输层,所以携带ProxyUpstream的新出站变体在被加进去之前,永远不会检查 TLS 设置、WebSocket host 或 gRPC authority。probe_target是穷尽的,也需要为新变体做出决定。build/inbound.rs→stream_transport对未列出的任何东西都返回None,所以没有加进去的新流式入站,无论其传输层怎么写,都以普通 TCP 提供服务。- 新的凭据种类涉及
entity/user.rs中的CredentialKind、Credential、Credentials字段和Credentials::get,build/users.rs→credential_kind和kind_of,build/validate.rs→validate_admission中的身份 match,以及build/inbound.rs→user_table中的用户表。
- 如果运行中的监听器无法就地接受这一变更,就从
topology/spec_plan/plan.rs→swap_disruption返回一个原因,并在supervisor/tests/unit/plan.rs中固定它。 - 在每个前端程序中降为 spec:
app/src/lower.rs、katana 的src/lower/,以及改写 spec 时的ffi/src/client.rs。检查同一输入降为 spec 两次仍得到相等的 spec。
新流式协议的其余部分(用户表、驱动、握手看门狗)列在监听器与服务循环;新出站的其余部分列在出站、UDP 扇出与负载均衡器。
app/tests/unit/lower.rs 固定 etemenanki-app 降为 spec 的结果。它被编译进库,所以用 cargo test -p etemenanki-app --lib 运行。它的证书和私钥文件内容是 CERT BYTES 和 KEY BYTES:lowering 只读取它们,能否解析由 supervisor 来判断。
| 测试 | 固定的行为 |
|---|---|
a_server_config_lowers_to_its_spec |
绑定,包括 listen 缺省时的回环默认值;每个入站一个用户集,以入站的 tag 命名;TLS 文件以字节读入;Shadowsocks 2022 服务端密钥定长为 16 字节,用户的密钥只做解码;没有账户的 HTTP 没有用户集;Unix socket 上的 SOCKS;伪装默认值已填入;WebSocket host 和 SNI 取自 tls.server_name;上游为 DialNetwork::Tcp;域名转小写以及匹配条件的顺序;DnsSpec::Single(Backend::System);Policies::default() |
lowering_twice_gives_the_same_spec |
lowering 是确定性的 |
a_spec_never_prints_a_credential |
其夹具中包在 Secret 里的值不出现在 Debug 输出中 |
users_are_keyed_by_the_name_the_config_gives_them |
Trojan、Shadowsocks、VLESS 和 VMess 用户以 Email 为 key;账户以 Username 为 key;Hysteria 2 用户有 email 时以 email 为 key |
a_user_keeps_its_key_when_its_password_changes |
key 相同,UserSpec 不相等 |
a_user_without_the_name_it_is_keyed_by_is_refused |
inbound <tag>: users[<i>] has no email; … 和 accounts[<i>] has no user |
two_users_with_one_name_are_refused |
两个条目都被指出,且不显示任何凭据 |
a_subscribe_file_splits_dns、a_dns_backend_needs_what_it_names |
订阅文件得到 DnsSpec::Split;服务器后端得到 DnsSpec::Single |
semantic_problems_are_left_to_the_supervisor |
lowering 是忠实的:两个同 tag 的入站、MTU 为 1000 的 TUN、既有密码又有用户且空闲超时越界、circuit 数为零的 Hysteria 2 入站、两个密码相同的 Trojan 用户、地址族没有对应地址的 WireGuard、密码为空且流数为零的 Hysteria 2 出站,以及指定了不存在出站的规则和负载均衡器,都原样进入 spec,交给 supervisor 拒绝 |
a_unix_listen_lowers_to_a_plain_stream |
Unix 监听器得到不带 TLS 的 StreamShape::Tcp |
a_tun_inbound_lowers_to_its_device |
TunSource::Create 带有设备的名字、地址和路由;TunSpec 取默认值 |
hysteria2_answers_to_its_aliases |
hysteria2、hysteria 和 hy2 都降为 Hysteria2;出站得到 DEFAULT_MAX_CONCURRENT_STREAMS |
syntax_errors_are_refused |
只有 TOML 前端程序才能检查的内容在 lowering 时被拒绝,其中包括无效的 UUID、未知的方法或 stream 网络、普通流上的 TLS 设置、缺少端口或证书文件、UDP 关闭时设置了 UDP 空闲超时、有 obfs_password 却没有 obfs、未知的 obfs,以及没有出站的配置(config defines no outbounds) |
supervisor/tests/unit/validate.rs 每条规则一个测试,每个测试在其余部分有效的 spec 中恰好违反那一条规则。它的夹具 spec() 是 127.0.0.1:1080 上的一个 SOCKS 监听器、一个 tag 为 direct 并作为路由默认目标的 freedom 出站,以及 DnsSpec::Single(Backend::System);with_inbound 和 with_outbound 向其中添加一个资源。TLS 材料可以是任意字节,因为校验从不解析 PEM。用 cargo test -p etemenanki-supervisor --lib 运行。不变量表混合了多个文件:从“tag 在各自的命名空间内唯一”到“在一个入站内,一个凭据只有一个所有者”这些行列出的是本文件的测试,其余行列出的是 app/tests/unit/lower.rs、supervisor/tests/unit/plan.rs 和 supervisor/tests/hot_swap.rs 的测试。本文件还有:
| 测试 | 固定的行为 |
|---|---|
the_base_spec_is_valid |
夹具通过校验 |
a_route_may_name_a_balancer |
负载均衡器 tag 是有效的路由目标 |
a_balancer_needs_a_member、a_balancer_member_needs_an_upstream_a_tcp_probe_can_reach |
EmptyBalancer;freedom、blackhole、Hysteria 2 和 WireGuard 成员的 UnprobeableMember |
a_hysteria2_shared_password_may_not_be_empty、a_hysteria2_udp_idle_timeout_is_between_2_and_600_seconds、hysteria2_serves_at_least_one_connection_and_one_circuit |
Hysteria 2 入站的取值范围,包括接受 2 和 600、拒绝 1 和 601,以及接受 None |
a_salamander_key_is_at_least_4_bytes_on_either_side、a_masquerade_may_not_answer_with_the_success_status |
两端都拒绝 3 字节、接受 4 字节;拒绝 233、接受 404 |
a_tun_device_mtu_is_at_least_1280、socks_over_a_unix_socket_needs_a_udp_bind_to_serve_udp |
拒绝 1279,接受 1280;Unix socket 上的 SOCKS 只有设置了 udp_bind 才能提供 UDP |
a_shadowsocks_2022_server_key_is_sized_to_its_method、shadowsocks_2022_client_keys_are_sized_to_their_method、a_shadowsocks_2022_user_key_must_normalise_to_the_method |
服务端和客户端密钥长度必须恰好相符;更长的用户密钥被折叠,更短的被拒绝 |
a_wireguard_address_family_needs_an_address_of_that_family、a_hysteria2_client_needs_a_password_and_a_stream |
出站规则 |
a_shared_credential_of_another_kind_is_no_conflict、a_hysteria2_username_may_not_hold_a_colon、a_hysteria2_password_may_not_be_empty |
取决于凭据种类的准入规则 |
其他固定 spec 语义的测试:
| 文件 | 测试 | 固定的行为 |
|---|---|---|
supervisor/tests/unit/plan.rs |
an_unchanged_spec_reuses_everything_and_publishes_nothing |
相等的 spec 只规划 Reuse |
supervisor/tests/unit/plan.rs |
an_invalid_spec_is_refused_without_a_plan |
plan 在规划任何东西之前拒绝违反规则的 spec |
supervisor/tests/unit/plan.rs |
a_user_set_change_alone_touches_neither_the_plane_nor_the_inbounds |
用户是独立的资源 |
supervisor/tests/unit/plan.rs |
a_route_change_alone_rebuilds_only_the_route_table |
路由作为整体比较,与出站分开 |
supervisor/tests/unit/plan.rs |
a_balancer_is_rebuilt_exactly_when_one_of_its_members_is、removing_a_balancer_alone_republishes_the_plane |
负载均衡器的相等比较包括其成员是否重建;仅移除一个负载均衡器也会发布 plane |
supervisor/tests/unit/plan.rs |
an_inbound_renamed_on_the_same_bind_swaps_and_closes_the_old_tags_sessions |
入站按绑定匹配;旧 tag 的会话被关闭 |
supervisor/tests/unit/plan.rs |
a_tun_settings_change_on_the_same_device_swaps_and_disrupts |
TUN 变更是一个 Disrupt 步骤 |
supervisor/tests/unit/plan.rs |
a_drain_takes_the_supervisor_default_unless_the_outbound_overrides_it、a_removed_outbound_drains_under_its_own_policy |
DrainPolicy 如何确定 |
supervisor/tests/unit/plan.rs |
a_dns_change_rebuilds_every_outbound_but_a_blackhole |
除黑洞外的每个出站都持有解析器 |
supervisor/tests/unit/plan.rs |
a_hysteria2_masquerade_change_swaps_without_disrupting、a_hysteria2_obfs_change_swaps_and_disrupts |
运行中的监听器能就地接受哪些 Hysteria 2 字段 |
supervisor/tests/hot_swap.rs |
an_unchanged_hysteria2_outbound_keeps_its_quic_connection_across_apply |
相等的出站 spec 保留其构建好的出站 |
supervisor/tests/hot_swap.rs |
a_speed_limit_change_keeps_the_users_sessions |
speed_limit 不参与准入 |
supervisor/tests/hot_swap.rs |
a_refused_spec_changes_nothing |
被拒绝的 spec 不触动正在运行的东西 |
environment/tests/unit/routing.rs |
domain_regex_equality_is_by_pattern、invalid_domain_regex_is_rejected |
DomainRegexSet 的相等比较及其拒绝 |
ffi/tests/proxy.rs |
a_server_inbound_is_refused、only_a_local_socks_inbound_is_served |
refuse_servers:VLESS 入站被拒绝,SOCKS 只在回环地址上提供服务 |
ffi/tests/proxy.rs |
a_tun_device_carries_tcp_and_udp_to_the_outbound |
supply_tun 为没有 TUN 入站的配置添加一个 tag 为 tun 的 TUN 入站 |
SuppliedTun 的相等比较、BindSpec 的 Display 和 Secret 的 Debug 在 supervisor crate 中都没有自己的测试;最后一项通过 app 的 a_spec_never_prints_a_credential 覆盖。修改其中任何一项时,请加一个测试。测试的布局见测试。