跳转到内容

spec:期望状态

源码文件:47 个 · 核对版本 Etemenanki 555b7df · katana v4.1.1
  • 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/spec_plan/plan.rs
  • Etemenanki/supervisor/src/topology/inbound/mod.rs
  • Etemenanki/supervisor/src/topology/balancer.rs
  • Etemenanki/supervisor/src/entity/user.rs
  • Etemenanki/supervisor/src/entity/id.rs
  • Etemenanki/supervisor/src/policy.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/supervisor/src/build/users.rs
  • Etemenanki/supervisor/src/build/inbound.rs
  • Etemenanki/supervisor/src/build/outbound.rs
  • Etemenanki/supervisor/src/build/dns.rs
  • Etemenanki/supervisor/src/build/route.rs
  • Etemenanki/supervisor/src/supervisor.rs
  • Etemenanki/supervisor/src/system/listener.rs
  • Etemenanki/concepts/src/net.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/transports/tls/config.rs
  • Etemenanki/protocols/src/transports/ws/endpoint.rs
  • Etemenanki/protocols/src/tun/device.rs
  • Etemenanki/protocols/src/tun/config.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/hysteria/server/masquerade.rs
  • Etemenanki/protocols/src/hysteria/server/endpoint.rs
  • Etemenanki/protocols/src/hysteria/server/config.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/protocols/src/ss_2022/crypto.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/ffi/src/client.rs
  • Etemenanki/app/tests/unit/lower.rs
  • Etemenanki/supervisor/tests/unit/validate.rs
  • Etemenanki/supervisor/tests/unit/plan.rs
  • Etemenanki/supervisor/tests/hot_swap.rs
  • Etemenanki/environment/tests/unit/routing.rs
  • Etemenanki/ffi/tests/proxy.rs
  • katana/src/lower/mod.rs
  • katana/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>)。
  • 知道自己是怎么写出来的。其中没有任何东西记录文件名、行号或面板字段。
前端程序 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)。

supervisor/src/topology/spec_plan/mod.rs
#[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 为资源命名,资源之间也靠 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 的检查顺序列在校验与应用错误。

supervisor/src/topology/spec_plan/mod.rs
#[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>

一个基于 WebSocket 和 TLS 的 Trojan 监听器,加一个 Unix socket 上的 SOCKS 监听器,两者准入同一个用户集;除一个被屏蔽的域名外,所有流量都直连:

Illustrative
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
  1. 降为 spec。 前端程序解析自己的格式,读取文件,填充默认值和空缺,构建一个 Spec。凡是会因语法而失败的,都在这里失败,并作为前端程序自己的错误报告。
  2. 移交。 SupervisorBuilder::start 和 Supervisor::start 接收第一份 spec,apply 和 apply_with 接收一份完整的新 spec,update 和 update_with 接收一个闭包 FnOnce(&mut Spec<U>)。actor 克隆运行中的 spec,在副本上运行这个闭包,然后像对待其他 spec 一样应用结果。check 接收 &Spec<U>,构建一切但不绑定。
  3. 校验。 plan(running, desired) 先调用 validate(desired)。违反规则的 spec 被整体拒绝,什么都不规划(an_invalid_spec_is_refused_without_a_plan)。
  4. 规划。 plan 逐个资源地比较期望的 spec 与 RunningState.spec(见相等语义),生成 Build、Reuse 和其他步骤。计划中含有 Disrupt 步骤时,除非这次应用允许中断,actor 会拒绝它。
  5. 准备与提交。 准备(prepare)阶段按计划从 spec 构建:build_dns、build_outbound、compile_routes(它读取 geo 文件),以及 build_handler,后者解析证书和私钥,并用 user_table 构建入站的用户表。它还为新的绑定打开监听器。提交(commit)阶段让这一切生效,并把期望的 spec 存为 RunningState.spec。
  6. 用户编辑。 set_users、upsert_user 和 remove_user 在运行中 spec 的副本里修改一个用户集(Actor::edit_users)。对每个引用该用户集的入站,actor 运行 validate_admission(而不是整个 validate),并重建该入站的用户表。只有每张表都建好之后,它才存入这些表,并把编辑后的副本存为运行中的 spec。对用户集中不存在的用户调用 remove_user 会返回 false,不做任何改变。之后的 update 从编辑后的用户集开始,之后的 apply 拿自己的用户集与编辑后的用户集比较。

每个阶段的完整过程见规划并应用变更。

supervisor/src/topology/spec_plan/inbound.rs
#[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。这些运行时类型见监听器与服务循环。

supervisor/src/topology/inbound/mod.rs
#[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 inbound
inbound h2: udp 0.0.0.0:8443 is bound by another inbound
inbound b: unix:/run/etemenanki/proxy.sock is bound by another inbound
inbound 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 拒绝这样的配置。
supervisor/src/topology/inbound/mod.rs
#[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 {}
protocols/src/tun/device.rs
#[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。

supervisor/src/topology/spec_plan/inbound.rs
#[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.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 头。 无

协议页面见 SOCKS 和 HTTP 代理。

这三种只有一个 transport 字段,并且总是需要用户集。Trojan 凭 password 准入,VLESS 和 VMess 凭 uuid。见 Trojan、VLESS 和 VMess:线格式与协议核心。

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。

supervisor/src/topology/spec_plan/inbound.rs
#[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:服务端。

supervisor/src/topology/spec_plan/inbound.rs
#[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 一律拒绝。

supervisor/src/topology/spec_plan/inbound.rs
#[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,与未配置伪装的上游服务端的应答逐字节相同。配置只设置了部分伪装键时,两个前端程序都用这些相同的值填充其余部分。

supervisor/src/topology/spec_plan/inbound.rs
#[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。

supervisor/src/topology/spec_plan/mod.rs
#[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:协议与客户端。

supervisor/src/topology/spec_plan/transport.rs
#[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。

supervisor/src/topology/spec_plan/transport.rs
#[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”
supervisor/src/topology/spec_plan/transport.rs
#[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,
}
protocols/src/transports/tls/config.rs
#[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)查询。

supervisor/src/topology/spec_plan/outbound.rs
#[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,
}
concepts/src/net.rs
#[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 与网络类型。

supervisor/src/topology/spec_plan/outbound.rs
#[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 扇出与负载均衡器。

supervisor/src/topology/spec_plan/outbound.rs
#[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)。
supervisor/src/topology/spec_plan/outbound.rs
#[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。

protocols/src/helpers/address_family.rs
#[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 失败。

supervisor/src/topology/spec_plan/route.rs
#[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 属于 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)。匹配规则见路由模型。

supervisor/src/topology/spec_plan/route.rs
#[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,
}
supervisor/src/topology/balancer.rs
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 扇出与负载均衡器。

supervisor/src/topology/spec_plan/dns.rs
#[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,
},
}
protocols/src/dns/mod.rs
#[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 解析器。

supervisor/src/entity/user.rs
#[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)。限速只在提交时发布,无论是应用的提交还是用户编辑的提交,所以被拒绝的变更不会改动当前生效的限速。限速的执行见流跟踪、统计与限速。

supervisor/src/entity/user.rs
#[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 与会话。

supervisor/src/entity/user.rs
#[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 标识,这是一个 u64 newtype,连接携带的是它而不是 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)。

supervisor/src/policy.rs
#[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 保存为 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)。完整的规划规则见规划并应用变更。

相等是结构性的:每个 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 的相等比较
  • 确定性。 同一输入降为 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

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。spec 这一侧的步骤:

  1. 用 #[derive(Debug, Clone, PartialEq, Eq)] 添加字段或变体。给值定类型:用枚举而不是字符串,用解码后的字节而不是文本,用字节而不是路径。机密值放在 Secret 中。没有 Eq 的类型需要手写 PartialEq,说明“相同”是什么意思,就像 DomainRegexSet 和 SuppliedTun 那样。
  2. 把关于该值如何与 spec 其余部分配合的每条规则都放进 build/validate.rs,并在 supervisor/tests/unit/validate.rs 中加一个测试:在其余部分有效的 spec() 夹具中恰好违反这条规则,再加上仍被接受的边界情况。前端程序只检查它自己的格式能表达、而 spec 不能表达的内容。
  3. 在对应的构建器中使用该值: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 中的用户表。
  4. 如果运行中的监听器无法就地接受这一变更,就从 topology/spec_plan/plan.rs → swap_disruption 返回一个原因,并在 supervisor/tests/unit/plan.rs 中固定它。
  5. 在每个前端程序中降为 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 覆盖。修改其中任何一项时,请加一个测试。测试的布局见测试。