跳转到内容

校验与应用错误

源码文件:44 个 · 核对版本 Etemenanki 555b7df · katana v4.1.1
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/supervisor/src/build/mod.rs
  • Etemenanki/supervisor/src/lib.rs
  • Etemenanki/supervisor/src/build/users.rs
  • Etemenanki/supervisor/src/build/inbound.rs
  • Etemenanki/supervisor/src/build/outbound.rs
  • Etemenanki/supervisor/src/entity/id.rs
  • Etemenanki/supervisor/src/entity/user.rs
  • Etemenanki/supervisor/src/supervisor.rs
  • Etemenanki/supervisor/src/topology/spec_plan/plan.rs
  • Etemenanki/supervisor/src/topology/spec_plan/inbound.rs
  • Etemenanki/supervisor/src/topology/spec_plan/transport.rs
  • Etemenanki/supervisor/src/topology/inbound/mod.rs
  • Etemenanki/supervisor/src/topology/balancer.rs
  • Etemenanki/supervisor/src/topology/plane.rs
  • Etemenanki/supervisor/src/system/listener.rs
  • Etemenanki/protocols/src/hysteria/server/masquerade.rs
  • Etemenanki/protocols/src/hysteria/auth.rs
  • Etemenanki/protocols/src/hysteria/obfs.rs
  • Etemenanki/protocols/src/tun/device.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/protocols/src/ss_2022/crypto.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/src/api.rs
  • Etemenanki/ffi/src/error.rs
  • Etemenanki/webclient/src/lib.rs
  • Etemenanki/webclient/src/router.rs
  • Etemenanki/supervisor/tests/unit/validate.rs
  • Etemenanki/supervisor/tests/unit/plan.rs
  • Etemenanki/supervisor/tests/hot_swap.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/masquerade.rs
  • Etemenanki/protocols/tests/unit/ss_2022/users.rs
  • Etemenanki/app/tests/unit/lower.rs
  • Etemenanki/app/tests/unit/subscribe.rs
  • katana/src/main.rs
  • katana/src/runtime.rs
  • katana/src/manager/node.rs
  • katana/src/lower/inbound.rs
  • katana/src/lower/mod.rs
  • katana/tests/unit/lower/outbound.rs

Spec<U> 是有类型的,但类型表达不了哪些字段必须搭配出现、哪些 tag 存在、哪些值在范围之内。这些规则集中在一个函数里:supervisor/src/build/validate.rs → validate。任何运行 spec(期望状态)的路径,都要在构建任何东西之前先经过它。违反任一规则的 spec 会被整个拒绝,返回一个 ApplyError,正在运行的内容照常运行。本页按 validate 的检查顺序列出每条规则,以及它产生的变体和确切文本;然后介绍其余的 ApplyError 变体、试运行 check(),以及各个前端程序如何报告这些错误。

本页面向这样的贡献者:给某个 spec 类型加字段、修改某条规则,或者编写一个把自己的格式降为 spec 的前端程序。spec 类型本身见 spec:期望状态。校验之后发生的事(计划、准备(prepare)和提交(commit))见规划与应用变更。

build/validate.rs 负责 spec 含义上的每一条规则。它的模块注释写明了分工:前端程序只负责语法,也就是解析自己的格式和读取文件,因此不会出现一条规则只在某一侧检查的情况。这个模块是纯的:不碰 socket、不碰文件、不读时钟。校验也从不解析 PEM;证书字节对它是不透明的,单元测试直接把 b"cert" 当作证书传入。

只有一条语义规则仍由协议的构建代码执行:使用 2022-blake3-chacha20-poly1305、且至少准入一个用户的 Shadowsocks 2022 入站能通过 validate,随后在 supervisor(监管器)准备这次应用(apply)时,被 protocols/src/ss_2022/users.rs → Validator::from_config 拒绝,结果是 ApplyError::Build(见 Build)。要修改这条规则,得改那里。

validate 检查:

  • tag 非空且在同类资源中唯一,并且没有负载均衡器与出站共用 tag;
  • 负载均衡器、路由规则、路由默认出站或入站所指定的每个 tag 都存在;
  • 每个负载均衡器成员都能通过 TCP 探测;
  • 每个出站各字段之间是否一致(TLS 设置与 shape、密钥长度、WireGuard 地址族);
  • 没有两个入站共用同一个绑定,并且每个协议都挂在它那一类绑定上;
  • 每个入站各字段之间是否一致(TLS 材料、开放模式、Hysteria 2 设置、TUN 的 MTU 和平台);
  • 每个入站将准入的用户(validate_admission)。

它不做这些事:

  • 读取证书、私钥、CA 证书包或 geodata。这些由准备阶段的构建代码解析和加载(compile_routes 读取 geodata),失败时是 ApplyError::Build。
  • 绑定任何东西。端口被占用会以 ApplyError::Bind 失败。
  • 决定默认值或补齐缺失的值。出站的 WebSocket host、gRPC authority 和 TLS 名称由前端程序填写;到达 supervisor 时仍缺失的值会被拒绝,而不是去猜(见 an_outbound_needs_a_ws_host_and_a_grpc_authority)。
  • 收集多个错误。它返回第一条被违反的规则,所以一次拒绝只报告一个问题。

入站和出站共用同一个 StreamShape(supervisor/src/topology/spec_plan/transport.rs)和 TLS 配对辅助函数 tls_mismatch,所以 TLS 规则只写一次,两侧通用。WebSocket host 和 gRPC authority 的规则只适用于出站。

调用方 运行什么 何时
topology/spec_plan/plan.rs → plan validate(desired),是它的第一条语句 每次 Supervisor::apply、apply_with、update、update_with、SupervisorBuilder::start 和 check
supervisor.rs → Actor::edit_users 对准入被编辑用户集的每个入站运行 validate_admission Supervisor::set_users、upsert_user 和 remove_user
supervisor.rs → Actor::prepare probe_target 构建负载均衡器时,用来找出每个成员的探测目标

plan 在算出任何一个步骤之前就先校验,所以没有哪条代码路径会把未经校验的 spec 交给构建代码。构建代码依赖这一点:build/mod.rs 写明每个 builder 接收的都是已校验的 spec,Actor::prepare 在构建负载均衡器的地方写了 expect("validated: members are outbounds") 和 expect("validated: members are probeable")。

supervisor/src/build/validate.rs
/// Check every semantic rule of `spec`.
pub fn validate<U: UserId>(spec: &Spec<U>) -> Result<(), ApplyError>;
/// Check the users `inbound` would admit from `set`: no credential with two
/// owners, and every credential usable by the inbound's protocol.
pub fn validate_admission<U: UserId>(
inbound: &InboundSpec,
set: &UserSet<U>,
) -> Result<(), ApplyError>;
/// Where a TCP health probe reaches `outbound`, if anywhere.
pub(crate) fn probe_target(outbound: &OutboundSpec) -> Option<&Destination>;

两个公开函数的路径是 etemenanki_supervisor::build::validate::validate 和 …::validate_admission,前端程序不用启动 supervisor 就能调用它们。build/ 下的模块中只有 apply 和 validate 是 pub;dns、inbound、outbound、route 和 users 是 pub(crate)。私有辅助函数如下:

辅助函数 返回 作用
unique_tags(kind, tags) Result<HashSet<&CompactString>, ApplyError> 按迭代顺序先拒绝空 tag,再拒绝重复 tag;返回的集合供引用检查使用
validate_outbound(outbound) Result<(), ApplyError> 逐个出站的规则
outbound_transport(transport) Result<(), CompactString> 先检查 TLS 配对,再检查 WebSocket host 和 gRPC authority
tls_mismatch(shape) CompactString TLS 配对错误的文本,取决于缺的是哪一侧
obfs(obfs) Result<(), CompactString> Salamander 密钥长度,入站和出站共用
validate_inbound(inbound) Result<(), ApplyError> 逐个入站的规则
invalid_inbound(inbound, reason) ApplyError 指向 Resource::Inbound(tag) 的 ApplyError::Invalid

在 validate 内部,引用检查用的就是 unique_tags 返回的集合。负载均衡器的成员在 by_tag 中查找,它是以各出站建立的 HashMap<&CompactString, &OutboundSpec>;每个路由目标都要经过闭包 routable,tag 在出站集合或负载均衡器集合中时它为真。validate_admission 把见过的标识(identity)记在 seen 中,它是一个 HashMap<Vec<u8>, &U>,从标识映射到第一个出示它的用户。

常量 类型 值 规则
MIN_TUN_MTU u16 1280 用户态协议栈能服务的最小 MTU
HY2_UDP_IDLE RangeInclusive<u64> 2..=600(秒,含两端) Hysteria 2 UDP 关联的空闲超时。注释给出了理由:低于下限,繁忙的关联会在两个包之间被清掉;高于上限,已失效的关联会占着它的 socket 长达十分钟
MIN_SALAMANDER_PSK usize 4 两侧允许的最短 Salamander 密钥。protocols/src/hysteria/obfs.rs → MIN_PSK_LEN 也是 4,Salamander::new 以 ProtocolError::Malformed("hysteria2 obfs psk is too short") 拒绝同样的密钥

规则还会读取 etemenanki-protocols 中的这些值:

值 来源 用途
STATUS_AUTH_OK = 233 protocols/src/hysteria/auth.rs 伪装响应(masquerade)唯一不能使用的状态码
Method::key_len():2022-blake3-aes-128-gcm 为 16,2022-blake3-aes-256-gcm 和 2022-blake3-chacha20-poly1305 为 32 protocols/src/ss_2022/crypto.rs Shadowsocks 2022 的密钥长度
normalise_psk(method, psk) protocols/src/ss_2022/users.rs 用户 PSK:长度等于方法要求时接受;更长时折叠到该长度(取其 SHA-256 的前 key_len 字节,ss_2022/crypto.rs → fold_key);更短时以 ErrorKind::InvalidInput 和 shadowsocks-2022: PSK too short (<len> < <n>) 拒绝

spec 中的 Shadowsocks 2022 密钥是解码后的字节。前端程序用 ss_2022/users.rs → decode_psk 解码密钥的 base64 文本,失败时返回 ErrorKind::InvalidInput 和 decode PSK: <e>;这是 lowering(降为 spec)阶段的错误,绝不会是 ApplyError。

supervisor/src/build/apply.rs
/// Why a spec was not applied.
///
/// A spec that fails validation is refused as a whole, and what was running
/// keeps running.
#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
/// Two resources of one kind share a tag, or a balancer's tag is also an
/// outbound's.
#[error("duplicate {kind} tag {tag}")]
DuplicateTag { kind: ResourceKind, tag: CompactString },
/// `from` names a tag nothing of that kind carries: a rule or a balancer
/// naming a missing outbound, or an inbound naming a missing user set.
#[error("{from} references unknown {kind} {tag}")]
UnknownReference { from: Resource, kind: ResourceKind, tag: CompactString },
/// A balancer with nothing to choose from.
#[error("balancer {balancer} has no members")]
EmptyBalancer { balancer: CompactString },
/// A balancer member no TCP health probe can reach, so its health, which
/// is all a balancer selects on, could never be known.
#[error("balancer {balancer}: outbound {member} has no upstream a TCP health probe can reach")]
UnprobeableMember { balancer: CompactString, member: CompactString },
/// A spec that breaks one of its documented invariants, such as TLS
/// material missing under a shape that layers TLS, or a Trojan inbound
/// without a user set.
#[error("{resource}: {reason}")]
Invalid { resource: Resource, reason: CompactString },
/// A resource's spec was valid, but constructing it failed: a
/// certificate that does not parse, geo data that cannot be read.
#[error("building {resource} failed: {source}")]
Build { resource: Resource, #[source] source: io::Error },
/// An inbound's listener could not be bound.
#[error("inbound {inbound}: binding {bind} failed: {source}")]
Bind { inbound: CompactString, bind: BindSpec, #[source] source: io::Error },
/// The spec needs a change that ends live connections, and the apply
/// did not allow one.
#[error("inbound {inbound}: {reason}; this ends its live connections and needs allow_disruptive")]
Disruptive { inbound: CompactString, reason: CompactString },
/// The supervisor has shut down.
#[error("the supervisor has shut down")]
Stopped,
}

枚举的文档注释写明了约定:未通过校验的 spec 被整个拒绝,正在运行的内容照常运行。lib.rs 在 crate 根重新导出了这个模块(pub use build::apply;),所以 etemenanki_supervisor::apply::ApplyError 和 etemenanki_supervisor::apply::ApplyReport 是同一组类型的第二条路径;etemenanki-app 从 build::apply 导入它们。

变体 阶段 产生者
DuplicateTag 校验 unique_tags,以及负载均衡器与出站的 tag 冲突检查
UnknownReference 校验 负载均衡器成员、路由规则或路由默认出站、入站的用户集指定了不存在的 tag
EmptyBalancer 校验 没有成员的负载均衡器
UnprobeableMember 校验 probe_target 返回 None 的负载均衡器成员
Invalid 校验、用户编辑 validate 和 validate_admission 的其余所有规则;以及 edit_users 遇到未知用户集时
Build 准备 某个 builder 或监听器准备步骤失败。校验已通过
Bind 准备 为新监听器调用的 system/listener.rs → bind
Disruptive plan 之后、准备之前 计划含有 Step::Disrupt,而 ApplyOptions::allow_disruptive 未设置
Stopped 任意 actor 已不在,或者没有正在运行的 spec 可供编辑

Build 和 Bind 既把 io::Error 写进文本,也作为 source() 返回。ApplyError 没有实现 Clone。

上面所有文本都建立在 supervisor/src/entity/id.rs 中的两个 Display 实现之上:

值 输出
ResourceKind::Inbound / Outbound / Balancer / UserSet inbound / outbound / balancer / user set
Resource::Inbound(tag) inbound <tag>
Resource::Outbound(OutboundId) outbound <tag>@v<version>
Resource::Balancer(tag) balancer <tag>
Resource::UserSet(tag) user set <tag>
Resource::Route route
Resource::Dns dns

绑定通过 topology/inbound/mod.rs → impl Display for BindSpec 输出:

BindSpec 输出
Tcp { host, port } <host>:<port>
Udp { host, port } udp <host>:<port>
Unix(path) unix:<path>
Tun(TunSource::Create(spec)) tun <name>;name 为 None 时是 tun auto
Tun(TunSource::Fd(device)) tun fd <n>

成功的应用会返回它对每个资源做了什么。这个类型和 ApplyError 一起定义在 build/apply.rs 中:

supervisor/src/build/apply.rs
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ApplyReport {
/// Kept as they were: their spec did not change.
pub reused: Vec<Resource>,
/// Constructed from their spec: new, or changed, in which case an
/// outbound is a new version of its tag.
pub built: Vec<Resource>,
/// Inbounds that kept their listener and now serve new connections with
/// a new handler.
pub swapped: Vec<CompactString>,
/// Outbound versions taken out of service. Their live flows are handled
/// by the drain policy.
pub drained: Vec<OutboundId>,
/// Inbounds whose listener was closed and bound again, because their
/// BindSpec changed.
pub rebound: Vec<CompactString>,
/// Inbounds whose runtime was stopped and started again on the handle it
/// kept, ending its live connections: the disruptive changes an apply
/// was allowed to make.
pub restarted: Vec<CompactString>,
/// Inbounds taken out of the spec: their listeners closed, and their
/// sessions with them.
pub removed: Vec<CompactString>,
}

期望 spec 中的每个资源都出现在 reused 或 built 中;其余五个列表说明这对已在运行的内容意味着什么。它没有实现 Display。每个列表如何填充见规划与应用变更。etemenanki-app 用 app/src/instance.rs → summary 输出它:每个非空列表输出为 <label> [<item>, …],用 ; 连接,顺序为 built、swapped、drained、rebound、restarted、removed、reused;全部为空时输出 nothing to run。各项按上面的 Display 形式输出,被排空的出站输出为 <tag>@v<version>。

supervisor/src/supervisor.rs
/// Validate `spec` and build everything it describes — outbounds, routes
/// with their geo data, handlers with their certificates, user tables —
/// without binding a listener or starting anything. It refuses what a start
/// would, short of a port or device that cannot be bound.
pub async fn check<U: UserId>(spec: &Spec<U>) -> Result<(), ApplyError>;

它在 crate 根以 etemenanki_supervisor::check 重新导出。见试运行。

一次应用中每个变体可能从哪里产生:

flowchart LR
  FE["Supervisor::apply"] -->|"Command::Apply"| PLAN["plan:先运行 validate"]
  FE -->|"actor 已不在"| ST["Stopped"]
  PLAN -->|"违反某条规则"| VE["DuplicateTag, UnknownReference, EmptyBalancer, UnprobeableMember, Invalid"]
  PLAN --> DIS["plan.disruptions()"]
  DIS -->|"未设置 allow_disruptive"| DE["Disruptive"]
  DIS --> PREP["prepare,bind = true"]
  PREP -->|"构建失败"| BE["Build"]
  PREP -->|"监听器无法绑定"| BI["Bind"]
  PREP --> COM["commit:不会失败"]

validate 内部的顺序。每个方框在遇到第一条被违反的规则时停止:

flowchart TB
  T1["用户集 tag"] --> T2["出站 tag"]
  T2 --> T3["负载均衡器 tag"]
  T3 --> T4["负载均衡器 tag 与出站 tag 相同"]
  T4 --> T5["入站 tag"]
  T5 --> O["validate_outbound,按顺序检查每个出站"]
  O --> B["每个负载均衡器:成员存在且可探测"]
  B --> R["按顺序检查路由规则,然后是默认出站"]
  R --> I1["下一个入站:绑定唯一,用户集存在"]
  I1 --> I2["validate_inbound"]
  I2 --> I3["validate_admission(入站指定了用户集时)"]
  I3 -->|"还有入站"| I1

入站检查是逐个入站进行的,所以第 2 个入站的准入检查先于第 3 个入站的绑定检查。在同一个列表中,报告的是按 spec 顺序第一个出错的项,只有一个例外:负载均衡器与出站的 tag 冲突检查遍历的是 balancers,也就是 unique_tags 返回的 HashSet,所以当多个负载均衡器 tag 与出站 tag 相同时,报告哪一个取决于集合先产出哪一个。

下面的文本凡是 app 能产生的,都是用固定版本的 etemenanki-app 以 --test 捕获的,其余则读自代码。尖括号中的占位符代表一个值。supervisor/tests/unit/validate.rs 中的单元测试匹配的是变体和资源,从不匹配文本。

unique_tags 对每类资源各运行一次,顺序是用户集、出站、负载均衡器、入站;负载均衡器与出站的 tag 冲突检查位于负载均衡器和入站之间。对每个 tag,它先检查是否为空,再检查是否重复。

规则 变体 文本 测试
用户集 tag 非空 Invalid { resource: UserSet("") } user set : a user set tag must not be empty no_tag_may_be_empty
用户集 tag 唯一 DuplicateTag { kind: UserSet } duplicate user set tag <tag> tags_are_unique_within_each_kind
出站 tag 非空 Invalid { resource: Outbound } outbound @v0: a outbound tag must not be empty no_tag_may_be_empty
出站 tag 唯一 DuplicateTag { kind: Outbound } duplicate outbound tag <tag> tags_are_unique_within_each_kind
负载均衡器 tag 非空 Invalid { resource: Balancer("") } balancer : a balancer tag must not be empty 无
负载均衡器 tag 唯一 DuplicateTag { kind: Balancer } duplicate balancer tag <tag> tags_are_unique_within_each_kind
负载均衡器不与出站共用 tag DuplicateTag { kind: Balancer } duplicate balancer tag <tag> a_balancer_may_not_share_an_outbounds_tag
入站 tag 非空 Invalid { resource: Inbound("") } inbound : a inbound tag must not be empty no_tag_may_be_empty
入站 tag 唯一 DuplicateTag { kind: Inbound } duplicate inbound tag <tag> tags_are_unique_within_each_kind

这些规则为什么是这样:

  • 出站和负载均衡器的 tag 共用一个命名空间。 路由按 tag 指定这两类中的任一类,同一个 tag 同时出现在两者上就会有歧义。入站 tag 和用户集 tag 各自独立:an_inbound_may_share_an_outbounds_tag 接受 tag 为 direct 的入站与 tag 为 direct 的出站并存。
  • 空 tag 是保留的。 supervisor 自己创建了两个目标,tag 都是空字符串:一是 topology/plane.rs → Plane::empty 中的 blackhole,Plane::empty 是 actor 在第一次应用发布 plane(数据平面)之前持有的 plane;二是作为出站访问的 DNS 服务(supervisor.rs → internal_id)。由于 spec 中没有空 tag,这两个目标都不会与 spec 中的任何出站混淆。
  • 检查按资源类别的顺序进行,而不是按文件顺序。 etemenanki-app 把入站下列出的用户降为一个用户集,tag 与入站自己的 tag 相同。因此两个 tag 都是 t 的 Trojan 入站会以 duplicate user set tag t 被拒绝,而不是 duplicate inbound tag t;tag 为空的 Trojan 入站则以 user set : a user set tag must not be empty 被拒绝。这两条文本都是从 app 捕获的。

validate_outbound 按 spec 顺序对每个出站运行。它返回的每个错误都是指向该出站(Resource::Outbound)的 ApplyError::Invalid。

对于经由传输层拨号的代理协议(SOCKS、HTTP、Trojan、VLESS、VMess、Shadowsocks 和 Shadowsocks 2022),outbound_transport 先检查上游的 OutboundTransportSpec:

规则 文本 测试
shape 叠加了 TLS,因此必须有 TLS 设置 outbound <tag>@v0: the transport layers tls but carries no tls settings an_outbound_carries_tls_settings_exactly_when_its_shape_layers_tls
有 TLS 设置,因此 shape 必须叠加 TLS outbound <tag>@v0: the transport carries tls settings but does not layer tls 同上
WebSocket shape 有 host outbound <tag>@v0: ws transport needs a host an_outbound_needs_a_ws_host_and_a_grpc_authority
gRPC shape 有 authority outbound <tag>@v0: grpc transport needs an authority 同上

配对由 StreamShape::uses_tls 判定:Tcp 从不叠加 TLS,Tls 总是叠加,Ws 和 Grpc 在其 tls 标志置位时叠加。

然后是协议自己的规则:

协议 规则 文本 测试
Shadowsocks 2022 psk 和 identity_psks 的每一项都恰好是 method.key_len() 字节 outbound <tag>@v0: shadowsocks-2022 keys must be <n> bytes for this method shadowsocks_2022_client_keys_are_sized_to_their_method
Hysteria 2 password 非空 outbound <tag>@v0: hysteria2 password must not be empty a_hysteria2_client_needs_a_password_and_a_stream
Hysteria 2 max_concurrent_streams 至少为 1 outbound <tag>@v0: max_concurrent_streams must be at least 1 同上
Hysteria 2 Salamander 密钥至少 4 字节 outbound <tag>@v0: salamander obfs key must be at least 4 bytes a_salamander_key_is_at_least_4_bytes_on_either_side
WireGuard address_family = Ipv4Only 要求 addresses 中有 IPv4 地址 outbound <tag>@v0: wireguard address_family ipv4_only needs an IPv4 address a_wireguard_address_family_needs_an_address_of_that_family
WireGuard address_family = Ipv6Only 要求有 IPv6 地址 outbound <tag>@v0: wireguard address_family ipv6_only needs an IPv6 address 同上

WireGuard 文本中的地址族名称来自 AddressFamilyStrategy::as_str。Auto、PreferIpv4 和 PreferIpv6 不要求特定地址。freedom 和 blackhole 出站在这里没有规则。

从 app 捕获的输出:

configuration invalid: outbound wg@v0: wireguard address_family ipv6_only needs an IPv6 address
configuration invalid: outbound hy2-out@v0: hysteria2 password must not be empty
configuration invalid: outbound hy2-out@v0: max_concurrent_streams must be at least 1
configuration invalid: outbound hy2-out@v0: salamander obfs key must be at least 4 bytes

按 spec 顺序对每个负载均衡器:

规则 变体 文本 测试
至少有一个成员 EmptyBalancer balancer <b> has no members a_balancer_needs_a_member
每个成员(按顺序)都是出站 tag UnknownReference { from: Balancer(b), kind: Outbound } balancer <b> references unknown outbound <m> a_balancer_member_must_be_a_known_outbound
每个成员都能通过 TCP 探测 UnprobeableMember balancer <b>: outbound <m> has no upstream a TCP health probe can reach a_balancer_member_needs_an_upstream_a_tcp_probe_can_reach

成员只在出站中查找,所以把另一个负载均衡器列为成员的负载均衡器会违反第二条规则:balancer outer references unknown outbound inner(a_balancer_member_may_not_be_another_balancer)。负载均衡器不能是任何东西的成员。

第三条规则由 probe_target 判定。负载均衡器只依据健康状态做选择,而它了解健康状态的方式是建立 TCP 连接,所以没有 TCP 上游的成员会永远被标记为不可用:

出站协议 probe_target
SOCKS、HTTP、Trojan、VLESS、VMess、Shadowsocks、Shadowsocks 2022 Some(&upstream.server)
freedom、blackhole None:没有上游
Hysteria 2、WireGuard None:上游监听的是 UDP

Actor::prepare 对每个成员再调用一次 probe_target,把得到的目标交给 Member::new。Balancer::new 仍会以 a balancer needs at least one outbound(一个 Build 错误)拒绝空成员列表,但校验会先拒绝这样的 spec。

按规则顺序,每条规则的 outbound,然后是 route.default,都必须指定一个出站或负载均衡器:

规则 变体 文本 测试
规则指定已知的出站或负载均衡器 UnknownReference { from: Route, kind: Outbound } route references unknown outbound <tag> a_route_rule_must_name_a_known_outbound_or_balancer
默认出站指定已知的出站或负载均衡器 同上 同上 the_route_default_must_name_a_known_outbound_or_balancer

即使本意是负载均衡器,文本里写的也是 outbound;类别是固定的,因为两者共用一个命名空间。a_route_may_name_a_balancer 接受指定负载均衡器的默认出站。geosite 和 geoip 代码是否存在不归校验判断:geodata 由 build/route.rs → compile_routes 读取,所以文件缺失或无法读取、或者代码不存在,都是 route 上的 Build 错误。

按 spec 顺序,对每个入站执行四步:

  1. 绑定唯一。 它的 BindSpec 与之前各入站的 BindSpec 比较(循环过程中收集到一个 Vec<&BindSpec> 里)。值相等时以 Invalid { resource: Inbound(<the later inbound>) }(指向后一个入站)失败,文本为 <bind> is bound by another inbound。捕获的输出:inbound b: 127.0.0.1:1080 is bound by another inbound。TCP 绑定和 UDP 绑定属于不同类别,所以 Hysteria 2 入站可以使用与 SOCKS 入站相同的端口号(the_same_port_over_udp_is_another_bind)。测试:two_inbounds_may_not_share_a_bind。

    “相等”指 BindSpec 的 PartialEq:

    BindSpec 相等的条件
    Tcp { host, port }、Udp { host, port } 类别相同、host 字符串按文本比较相同、端口相同
    Unix(path) 两个 PathBuf 相等,比较的是路径组件:重复的 / 和中间的 . 会被合并,.. 不会
    Tun(TunSource::Create(spec)) 两个 DeviceSpec 逐字段相等
    Tun(TunSource::Fd(device)) topology/inbound/mod.rs → impl PartialEq for SuppliedTun:描述符编号相同且 MTU 相同
  2. 用户集存在。 当 users 为 Some(tag) 而没有用户集带有这个 tag 时:UnknownReference { from: Inbound(tag), kind: UserSet },输出为 inbound <tag> references unknown user set <set>。测试:an_inbound_must_name_a_known_user_set。

  3. validate_inbound,见下文。

  4. validate_admission:入站指定了用户集时,针对该用户集运行。见准入。

每个错误都是 Invalid { resource: Inbound(tag) },输出为 inbound <tag>: <reason>。

首先检查配对。每个协议只在一类绑定上提供服务:

协议 需要的绑定
SOCKS、HTTP、Trojan、VLESS、VMess、Shadowsocks、Shadowsocks 2022 BindSpec::Tcp 或 BindSpec::Unix
Hysteria 2 BindSpec::Udp
TUN BindSpec::Tun

其他任何组合都以 the protocol cannot be served on <bind> 失败,例如 inbound in: the protocol cannot be served on udp 127.0.0.1:2000。测试:each_protocol_is_served_only_on_its_kind_of_bind。

对于带有 InboundTransportSpec 的协议(HTTP、Trojan、VLESS、VMess),接下来按以下顺序检查传输层:

规则 reason 文本 测试
在 Unix 绑定上,shape 是 StreamShape::Tcp a unix socket carries no transport; its shape must be plain tcp a_unix_listener_carries_only_the_plain_tcp_shape
shape 叠加了 TLS,因此必须有 TLS 材料 the transport layers tls but carries no tls settings an_inbound_carries_tls_material_exactly_when_its_shape_layers_tls
有 TLS 材料,因此 shape 必须叠加 TLS the transport carries tls settings but does not layer tls 同上

入站的 WebSocket host 和 gRPC authority 可以是 None:服务端没有可以借用这两个值的来源,也用不到它们(an_inbound_needs_no_ws_host_or_grpc_authority)。

然后是协议自己的规则:

协议 规则 reason 文本 测试
SOCKS 在 Unix 绑定上开启 udp 时,必须设置 udp_bind socks over a unix socket has no local IP for UDP associate; set udp_bind or turn udp off socks_over_a_unix_socket_needs_a_udp_bind_to_serve_udp
Trojan、VLESS、VMess users 为 Some the protocol has no open mode and needs a user set trojan_vless_and_vmess_need_a_user_set
Shadowsocks 无
Shadowsocks 2022 服务端 psk 恰好是 method.key_len() 字节 the shadowsocks-2022 key must be <n> bytes for this method a_shadowsocks_2022_server_key_is_sized_to_its_method
Hysteria 2 不同时设置 shared_password 和 users a shared password and a user set cannot both be set; a credential would have two answers hysteria2_takes_a_shared_password_or_a_user_set_but_not_both
Hysteria 2 两者设置了其中之一 hysteria2 needs a shared password or a user set 同上
Hysteria 2 共享密码非空 the shared password must not be empty a_hysteria2_shared_password_may_not_be_empty
Hysteria 2 设置了 udp_idle_timeout 时,它是 2 到 600 整秒 udp_idle_timeout must be between 2 and 600 seconds a_hysteria2_udp_idle_timeout_is_between_2_and_600_seconds
Hysteria 2 max_connections 和 max_circuits 至少为 1 max_connections and max_circuits must be at least 1 hysteria2_serves_at_least_one_connection_and_one_circuit
Hysteria 2 Salamander 密钥至少 4 字节 salamander obfs key must be at least 4 bytes a_salamander_key_is_at_least_4_bytes_on_either_side
Hysteria 2 伪装响应能用 Masquerade::new 构建 构造函数的错误文本 a_masquerade_may_not_answer_with_the_success_status
TUN 设备 MTU(TunSource::mtu)至少为 1280 tun mtu must be at least 1280 a_tun_device_mtu_is_at_least_1280
TUN TunSource::Create 设备通过 check_platform 平台检查的错误文本 无

决定边界情况的细节:

  • 空闲超时按整秒比较(Duration::as_secs)。None 表示关闭 UDP 中继,不需要范围约束;a_hysteria2_udp_idle_timeout_is_between_2_and_600_seconds 接受 2 和 600,拒绝 1 和 601,并接受 None。

  • 共享密码或用户集。 如果两者都设置,同一个凭据就可能有两种答案。设置了共享密码时,Hysteria2InboundSpec::user_auth 被忽略。

  • 伪装响应。 protocols/src/hysteria/server/masquerade.rs → Masquerade::new 拒绝两种情况,校验把它的文本包装成 reason。如果用成功状态码回应未认证的请求,就等于告诉探测者它已经通过了认证,所以 233 被拒绝。http::StatusCode::from_u16 接受 100 到 999,超出这个范围的一律拒绝:

    inbound hy2: hysteria2: 233 is the authentication success status and cannot be used for the masquerade
    inbound hy2: hysteria2: 1000 is not an HTTP status code

    build/inbound.rs 中的 hy2_handler 在构建 handler(处理器)时会再次调用 Masquerade::new;有问题的伪装响应已经先被校验拒绝了。masquerade 为 None 时,handler 使用 Masquerade::default():状态码 404,响应体 404 page not found\n,内容类型 text/plain; charset=utf-8,也就是一个未做配置的 Go 服务器给出的响应。

  • TUN 平台检查。 在 Linux 以外的所有平台上,protocols/src/tun/device.rs → check_platform 会以 ErrorKind::Unsupported 拒绝带 routes 的设备 spec:inbound <tag>: tun routes are installed only on Linux; add them with the OS route tool。在 Linux 上它接受任何 spec。桌面版 tun::open(除 Android 和 iOS 外的所有目标都会编译)调用同一个函数,所以 --test 和真正启动的结论一致。在 Android 和 iOS 上,tun::open 以 Unsupported 和 tun devices are created by the system VPN API here; adopt its descriptor 拒绝所有设备;这发生在绑定时,所以是 check 看不到的 Bind 错误。外部提供的设备(TunSource::Fd)跳过平台检查:它由所在平台自己配置好。它的 MTU 仍会被检查。

  • Shadowsocks 2022 服务端密钥。 服务端 psk 必须恰好是该方法要求的长度;spec 类型写明它是“decoded and sized to method”(已解码并按 method 定长),所以定长是前端程序的事。etemenanki-app 在 lowering 时自己用 normalise_psk 给服务端密钥和每个客户端密钥定长,所以 the shadowsocks-2022 key must be <n> bytes for this method 和 shadowsocks-2022 keys must be <n> bytes for this method 都不会从 app 出现(见 etemenanki-app)。用户密钥更宽松(见下文)。

validate_admission 检查入站将从其用户集中准入的用户。validate 对每个指定了用户集的入站调用它,Actor::edit_users 对准入被编辑用户集的每个入站调用它,所以用户编辑与完整的应用遵循同样的规则。

一个入站只按一种凭据类型准入用户,见 build/users.rs → credential_kind:

入站协议 凭据类型
SOCKS、HTTP Account
Hysteria 2 Hysteria2UserAuth::Account 时为 Account,Hysteria2UserAuth::Password 时为 Password
Trojan、Shadowsocks Password
VLESS、VMess Uuid
Shadowsocks 2022 Ss2022Psk
TUN 无:准入直接返回 Ok(())

没有这种凭据的用户会被跳过,在这里如此,构建入站时也如此。对其余每个用户,函数按 key 顺序(用户集是以前端程序的 UserId 为 key 的 BTreeMap)推导出一个标识(见下面第一张表),把它插入 seen,然后按第二张表的顺序执行四项检查,遇到第一个失败就停止:

凭据 比较的标识
Uuid UUID 的 16 个字节
Password 密码的 UTF-8 字节
Ss2022Psk 解码后、规范化之前的原始 PSK 字节
Account 用户名的字节,在 Hysteria 2 入站上先做 ASCII 小写转换。账户密码不参与比较
检查 reason 文本 测试
没有两个用户出示相同的标识 账户为 users <a> and <b> present the same username,其他为 users <a> and <b> present the same credential two_users_may_not_present_the_same_credential、hysteria2_usernames_collide_case_insensitively
Shadowsocks 2022:用户 PSK 能规范化到该方法的长度 user <name>: shadowsocks-2022: PSK too short (<len> < <n>) a_shadowsocks_2022_user_key_must_normalise_to_the_method
Hysteria 2 账户:名称不含 :,名称和密码都非空 user <name>: a hysteria2 account needs a name without ':' and a password a_hysteria2_username_may_not_hold_a_colon
Hysteria 2 按密码准入:密码非空 user <name>: a hysteria2 password must not be empty a_hysteria2_password_may_not_be_empty

每个都是 Invalid { resource: Inbound(tag) },输出为 inbound <tag>: <reason>。原因如下:

  • 一个凭据,一个所有者。 如果两个用户出示同一个凭据,流量算到谁头上就取决于用户表匹配到哪一个。<a> 是按 key 顺序先见到的用户,<b> 是第二个。
  • 只看准入所用的类型。 两个用户共用一个密码,在按账户准入的 SOCKS 入站上不构成冲突(a_shared_credential_of_another_kind_is_no_conflict)。
  • Hysteria 2 不区分大小写。 Hysteria 2 在线路上比较用户名时不区分大小写(Authenticator::user_pass 同样用 to_ascii_lowercase 转小写),所以 Alice 和 alice 在那里是同一个用户名,也只有在那里是。在 SOCKS 入站上,这两个名字是不同的。
  • Hysteria 2 的线路格式是 user:pass。 含冒号的名称根本无法出示,空的部分也不算凭据。SOCKS 用单独的字段携带名称,接受冒号(a_hysteria2_username_may_not_hold_a_colon 也检查了 SOCKS 入站)。
  • 更长的 Shadowsocks 2022 用户密钥会被折叠,而不是被拒绝:normalise_psk 取其 SHA-256 的前 key_len 字节。准入该用户的每个入站都把密钥规范化到自己方法的长度,这就是这项检查放在准入里、而不放在用户本身上的原因。

准入文本从不输出密码、UUID 或密钥,密钥错误只输出长度。用户以 UserId::name() 命名,它是一个 supervisor/src/entity/user.rs → UserName(Email 或 Username),其 Display 就是字符串本身:

前端程序 UserId name()
etemenanki-app app/src/lower.rs → UserKey,即 UserName 本身 用户的 email。SOCKS 或 HTTP 账户,以及没有 email 的 Hysteria 2 用户,以其认证所用的用户名命名,所以对这些用户,文本中显示的是该用户名
katana src/lower/mod.rs → Uid(i64),即面板用户 id 十进制 id 的 UserName::Username

从 app 捕获的输出:

configuration invalid: inbound t: users a@example.com and b@example.com present the same credential
configuration invalid: inbound ss: users alice and bob present the same credential
configuration invalid: inbound ss: user alice: shadowsocks-2022: PSK too short (15 < 16)
configuration invalid: inbound hy2: users Alice and alice present the same username
configuration invalid: inbound hy2: user a:b: a hysteria2 account needs a name without ':' and a password
configuration invalid: inbound hy2: user a: a hysteria2 account needs a name without ':' and a password

最后一行是一个密码为空的用户。

Actor::apply 在 plan 返回之后、准备任何东西之前检查计划:

supervisor/src/supervisor.rs
let plan = plan(&self.state, &spec)?;
if !options.allow_disruptive
&& let Some((inbound, reason)) = plan.disruptions().next()
{
return Err(ApplyError::Disruptive { inbound: inbound.clone(), reason: reason.clone() });
}

只报告按步骤顺序的第一个中断性变更。Plan::disruptions() 按计划中的顺序产出 Step::Disrupt 步骤,而 plan 把它们收集在一个 handler 替换列表里,按 spec 顺序逐个入站加入,并把这个列表追加在 Step::PublishPlane 之后。因此报告的是按 spec 顺序第一个有中断性变更的入站。文本为 inbound <tag>: <reason>; this ends its live connections and needs allow_disruptive,例如 inbound hy2-in: the hysteria2 obfuscation changed; connected clients cannot follow the new key; this ends its live connections and needs allow_disruptive。哪些变更属于中断性变更、以及每条 reason 文本,见规划与应用变更。被拒绝的中断性 spec 不绑定、也不构建任何东西。测试:supervisor/tests/hot_swap.rs 中的 a_hysteria2_obfuscation_change_needs_allow_disruptive 和 a_tun_device_change_needs_allow_disruptive。

spec 是合法的,但构建它的某个资源失败了。Actor::prepare 给每个失败附上它正在构建的资源:

文本中的资源 构建者 典型原因
dns build/dns.rs → build_dns 某个解析器无法构建;或者对带 use_hosts 的 split spec,系统 hosts 文件无法读取
outbound <tag>@v<n> build/outbound.rs → build_outbound CA 证书包无法解析
balancer <tag> Balancer::new 成员列表为空,校验会先拒绝
route build/route.rs → compile_routes geodata 缺失、无法读取或不含被引用的代码
inbound <tag> build_handler、prepare_swap、Pending::pair、user_table、prepare_users 证书或私钥无法解析;QUIC endpoint 无法在其 socket 上打开;TUN 设备无法准备
inbound <tag> user_table,经由 protocols/src/ss_2022/users.rs → Validator::from_config 使用 2022-blake3-chacha20-poly1305 且至少准入一个用户的 Shadowsocks 2022 入站:shadowsocks-2022: multi-user requires an aes-gcm method(InvalidInput)。这是 validate 唯一不检查的语义规则

从 app 捕获的输出:

configuration invalid: building inbound v failed: no certificate in PEM bundle
configuration invalid: building inbound hy2 failed: hysteria2: the certificate file contains no certificates
configuration invalid: building outbound t-out@v1 failed: no certificate in CA PEM bundle
configuration invalid: building route failed: a geosite matcher is used but no geosite file is configured
configuration invalid: building inbound ss failed: shadowsocks-2022: multi-user requires an aes-gcm method

有些构建代码会重复一条校验已经执行过的规则,作为兜底检查。万一走到这里,每一项都以 Build 失败:

兜底检查 文本 重复的规则
build/inbound.rs → hy2_handler Masquerade::new 的文本 伪装响应规则
build/inbound.rs → user_table 包在 normalise_psk 错误外面的 inbound <tag>: user <label>: <e> Shadowsocks 2022 用户密钥规则
build/inbound.rs → mismatch inbound <tag>: protocol and bind do not match 协议与绑定的配对:TUN 协议的绑定不是设备,或者流式协议的用户表构建出来是 Hysteria 2 的表或者没有表
build/inbound.rs → build_transport missing tls 入站 TLS 配对
build/outbound.rs → transport missing tls、missing ws host、missing grpc authority 出站 TLS 配对,以及 WebSocket 和 gRPC 规则
system/listener.rs → check_obfs 来自 Salamander::new 的 malformed: hysteria2 obfs psk is too short,错误类型为 InvalidInput Salamander 密钥长度
system/listener.rs → mismatch the handler is not of the kind its listener serves 在 Pending::pair、prepare_swap 和 prepare_users 中,handler 或用户表的类别与其监听器不符
topology/balancer.rs → Balancer::new a balancer needs at least one outbound 空负载均衡器规则

check_obfs 在准备阶段运行(在 Pending::pair 和 prepare_swap 中),这样把运行中的监听器切换到新密钥时,就不会在提交阶段失败。

Validator::from_config 还有一个不会失败的兜底:两个被准入用户的密钥在规范化后相等时,它保留第一个、跳过第二个,并以 WARN 级别记录 shadowsocks-2022: users "<a>" and "<b>" share a key; only "<a>" is matched。protocols 的测试 users_sharing_a_psk_resolve_to_the_first 固定了这一行为。

为新监听器调用的 system/listener.rs → bind 失败了:TCP、UDP 或 Unix 绑定失败,Unix 路径上存在的不是 socket,或者创建 TUN 设备(tun::open)、接管外部提供的设备(tun::adopt)、复制设备描述符(try_clone)失败。文本为 inbound <tag>: binding <bind> failed: <io error>。只有 listener::bind 会产生 Bind。Pending::pair 对已绑定 socket 所做的事(打开 Hysteria 2 QUIC endpoint、准备 TUN 设备)失败时是 Build。

对 Unix 路径,bind 先看路径上有什么(symlink_metadata):

路径上 bind
没有东西 直接绑定
一个 socket 视为之前崩溃的运行留下的陈旧文件:删除后绑定。同一个 supervisor 在该路径上的存活监听器拥有相同的绑定,所以计划会保留它,不会再次绑定
其他任何东西 以 ErrorKind::AlreadyExists 和 <path> exists and is not a socket 拒绝,例如 inbound socks: binding unix:/run/etemenanki/socks.sock failed: /run/etemenanki/socks.sock exists and is not a socket

bind 拿到 TUN 设备时以 INFO 级别记录日志:创建的设备记录 inbound <tag> owns tun device <name>,接管的设备记录 inbound <tag> serves a supplied tun device。这些日志出自准备阶段,所以即使这次应用随后被拒绝,它们也会出现。inbound <tag> listening on <bind> 只在提交阶段启动监听器时记录。

Supervisor 上每个修改或读取 actor 所持有内容的方法(apply、apply_with、update、update_with、set_users、upsert_user、remove_user、close、sessions、shutdown)都经过 ask:它在 actor 的通道(容量 16)上发送一个 Command,然后等待 oneshot 回复。任一步失败都变成 Stopped:actor 已经关闭,或者它丢弃了回复。没有正在运行的 spec 可编辑时,Command::Update 和 edit_users 也返回 Stopped。close 和 sessions 把 Stopped 分别转成 0 和空列表,shutdown 则忽略它。

set_users、upsert_user 和 remove_user 不运行 validate:它们只修改一个用户集,别的都不动。Actor::edit_users 的失败情况:

情况 错误 文本
没有正在运行的 spec Stopped the supervisor has shut down
没有用户集带有该 tag Invalid { resource: UserSet(tag) } user set <tag>: no such user set
准入该用户集的某个入站拒绝了某个用户 Invalid { resource: Inbound(tag) } 准入一节的文本
用户表无法构建,或与其监听器不匹配 Build { resource: Inbound(tag) } building inbound <tag> failed: <io error>

对不在用户集中的用户调用 remove_user 返回 Ok(false),不做任何检查。所有用户表都在存入任何一张之前准备好,所以被拒绝的编辑一张也不会存入。该路径的其余部分见用户、principal 与会话。

check 在一个用完即弃的 actor 上执行一次应用的前半部分:

supervisor/src/supervisor.rs
pub async fn check<U: UserId>(spec: &Spec<U>) -> Result<(), ApplyError> {
let mut actor = Actor::new(SocketOptions::default());
let plan = plan(&actor.state, spec)?;
actor.prepare(&plan, spec, false).await.map(drop)
}

Actor::new 构建 actor 的状态(根 token、TaskTracker、Sessions、Tracker、采样器),不 spawn 任何任务。它的运行状态是 RunningState::empty(),所以计划会构建每一个资源、不复用任何资源,也没有 Disrupt 步骤。随后 prepare 以 bind = false 运行,它构建的一切在 check 返回时被丢弃。

check 覆盖的内容:

步骤 做了什么 可能的失败
validate 本页的每条规则 校验阶段的各个变体
build_dns 各解析器;对带 use_hosts 的 split spec,读取系统 hosts 文件 dns 上的 Build
对每个出站运行 build_outbound 传输层 connector(连接器)、TLS 客户端配置和 CA 证书包、协议密钥 outbound <tag>@v<n> 上的 Build
对每个负载均衡器运行 Balancer::new 成员及其探测目标;不启动探测任务 balancer <tag> 上的 Build
compile_routes 路由表,以及规则引用的 geodata 集合(从文件读取) route 上的 Build
对每个入站运行 admit 准入结果和用户 key 无
对每个入站运行 build_handler 由 PEM 字节构建的服务端 TLS 配置、Hysteria 2 的 QUIC 服务端配置和伪装响应、用户表(包括 Validator::from_config) inbound <tag> 上的 Build

check 看不到的内容:

未做的事 启动时在哪里发生 失败类型
绑定 TCP、UDP 和 Unix 监听器,包括检查 Unix 路径上没有其他文件 listener::bind Bind
创建 TUN 设备并安装其路由(tun::open),或接管外部提供的描述符(tun::adopt) listener::bind Bind
在 socket 上打开 Hysteria 2 endpoint(Hy2Inbound::open)、检查其混淆密钥(check_obfs)、准备 TUN 设备(TunInbound::prepare) Pending::pair Build
相对于正在运行的 supervisor 的中断性变更 Actor::apply Disruptive
任何网络上的事:解析上游、连接上游、探测负载均衡器成员 提交之后 不是 ApplyError

所以除了绑定时的那些构造步骤之外,check 与启动在每条语义规则和每个构造函数上结论一致。通过 check 的 spec 仍可能被正在运行的 supervisor 以 Disruptive 拒绝,因为 check 是从空状态开始规划的。app 可以演示绑定上的这个缺口:listen 指向一个已存在的普通文件的 SOCKS 入站能以 Configuration OK. 通过 --test,而启动时会以 Bind 被拒绝。

check 在调用方的任务上运行。它不接受任何 SupervisorBuilder 设置:它的 actor 使用 SocketOptions::default()。socket 策略在这里没有作用,因为 check 不拨号。

调用方:

调用方 函数 检查什么
etemenanki-app --test app/src/instance.rs → check → check_bytes 配置文件及其指定的订阅文件,合并并降为 spec;以及 [api] 选项(crate::api::options);然后对 spec 运行 etemenanki_supervisor::check。因此有问题的 [api] 会让 --test 以 LoadError::Config 失败
etemenanki-app REST API app/src/api.rs → ConfigFile::set_route → check_bytes 编辑后的配置,在写入之前
katana src/runtime.rs → check_node → lower::test_spec 进程级的出站和 DNS、节点自己的路由表(不含审计规则),以及 Hysteria 2 节点在本地配置的入站
测试 app/tests/unit/subscribe.rs → every_lowered_node_builds_and_the_dns_setup_with_it;katana tests/unit/lower/outbound.rs → wireguard_ipv4_only_builds_with_ipv4_address、wireguard_ipv6_only_requires_ipv6_address 降为 spec 后的订阅示例;katana 的 WireGuard lowering

在 katana 中,build_node(用于启动时,以及重载新增的节点)、test_config(--test)和 apply_reload 都会调用 check_node,每处都在构建节点的面板客户端之后。对 Hysteria 2 节点,test_spec 用 [node.hysteria] 降出入站(local_hysteria_node,端口 0 替换为 1),并准入一个占位用户(uid 0,email placeholder)。对其他所有节点类型,入站及其用户来自面板,不在检查范围内。

supervisor 返回 ApplyError,对拒绝本身不记任何日志。它关于应用的唯一一条日志是提交结束时的 DEBUG 日志 applied: <n> built, <n> reused, <n> swapped, <n> drained(supervisor.rs → Actor::commit)。各前端程序自行补充上下文。

app/src/instance.rs → LoadError 合并了两种失败来源,两者都是 #[error(transparent)],所以文本就是内层错误的文本:

app/src/instance.rs
pub enum LoadError {
/// The files could not be read, or what they say could not be lowered.
Config(#[from] io::Error),
/// The supervisor refused the spec.
Apply(#[from] ApplyError),
}

app 的 lowering(app/src/lower.rs → lower)自己拒绝语法和文件问题(LoadError::Config),把本页的规则留给 supervisor,只有两个例外:

  • Shadowsocks 2022 服务端和客户端密钥。 spec 要求它们已按方法定长,所以 lowering 用 normalise_psk 定长:更长的密钥被折叠,过短的被拒绝,文本为 inbound <tag>: shadowsocks-2022: PSK too short (<len> < <n>) 或 outbound <tag>: shadowsocks-2022: PSK too short (<len> < <n>)。用户的密钥只解码、不定长,因为它的长度取决于准入它的入站,所以由 supervisor 的准入检查来定长。
  • 用户名。 用户集是一个 map,同名的第二项会覆盖第一项。lowering 会拒绝缺少其协议用作 key 的那个名字的用户(inbound <tag>: users[<i>] has no email; a user of this inbound is named by its email,账户则为 … has no user),也会拒绝同名的两项(inbound <tag>: users[<i>] and users[<j>] are both named "<name>";SOCKS 和 HTTP 为 accounts[…])。两个不同的名字出示同一个凭据的情况留给准入处理。

app/tests/unit/lower.rs → semantic_problems_are_left_to_the_supervisor 对一份违反本页多条规则的配置做 lowering(入站 tag 重复、TUN MTU 为 1000、Hysteria 2 入站同时设了密码和用户、UDP 空闲超时 1 秒且 circuit 数为零、Trojan 共用密码、WireGuard 地址族没有匹配的地址、Hysteria 2 客户端的流数为零、负载均衡器和规则指定了不存在的出站),并断言 lowering 接受它、把这些值原样带进 spec。syntax_errors_are_refused 固定了 app 自己拒绝的内容。lowering 本身见etemenanki-app:从 TOML 到 spec。

从 app 捕获的输出。lowering 自己的拒绝不带 supervisor 的措辞:

configuration invalid: inbound ss: shadowsocks-2022: PSK too short (16 < 32)
configuration invalid: outbound ss-out: shadowsocks-2022: PSK too short (15 < 16)
configuration invalid: inbound t: users[0] and users[1] are both named "alice@example.com"
configuration invalid: inbound t: users[1] has no email; a user of this inbound is named by its email

app 的 tracing_subscriber::fmt subscriber 写到 stdout,ERROR 日志也一样。--test 和启动相关的日志由 target etemenanki_app 记录(app/src/main.rs);config loaded、config reloaded 和重载错误由 target etemenanki_app::instance 记录。

场景 输出
--test,通过 stdout 上输出 Configuration OK.,退出码 0
--test,被拒绝 ERROR 级别的 configuration invalid: <e>,退出码 1
启动,被拒绝 ERROR 级别的 failed to start: <e>,退出码 1
启动,API 无法启动 ERROR 级别的 failed to start: API on <listen>: <e>,退出码 1
启动,已应用 INFO 级别的 config loaded: <summary>
重载,已应用 INFO 级别的 config reloaded: <summary>
重载,文件无法读取 ERROR 级别的 reload: cannot read <path>: <e>
重载,以 Disruptive 被拒绝 ERROR 级别的 reload refused, keeping the running config: inbound <tag>: <reason>, which would end its live connections; restart to apply it
重载,其他拒绝 ERROR 级别的 reload: <e>; keeping the running config

app 用 SupervisorBuilder::start 启动(Instance::start → Core::start,配合 Supervisor::builder()),用 Supervisor::apply 重载,两者都使用默认的 ApplyOptions,所以从不设置 allow_disruptive:它会拒绝修改 TUN 入站(设备或其设置)或 Hysteria 2 监听器混淆的重载,重启即可应用。<summary> 是 ApplyReport 一节描述的 summary 格式。重载路径见etemenanki-app:运行、重载与关停。

对 REST API,impl From<LoadError> for ControlError 按责任归属对失败分类。Bind 和 Stopped 变成 ControlError::Failed(HTTP 500):不是配置的错。其余所有 ApplyError 变成 ControlError::Invalid(HTTP 422)。lowering 产生的 io::Error 在其 kind 为 InvalidData 或 InvalidInput 时是 Invalid,否则是 Failed。另外两个变体不来自加载:UnknownGroup(HTTP 404,no route group named "<group>")和 Stale(HTTP 409,the config file has changed since it was read; its ETag is now <etag>)。webclient/src/router.rs 把每个变体映射到对应的状态码。ConfigFile::set_route 在两处以 Stale 拒绝:一处在编辑之前,If-Match 指定的是另一个 ETag 时;另一处在 check_bytes 之后,磁盘上的文件已不再是它读到的字节时。它在那个时刻重新读取文件,是因为检查要构建启动时会构建的一切,需要一段时间,而这期间手工保存的修改不能被覆盖。

ffi/src/error.rs → impl From<LoadError> for FfiError 先挑出客户端自己对服务端入站的拒绝,即 FfiError::ServerInbound,输出为 inbound "<tag>" serves <protocol>, a server protocol; only a tun inbound and local socks or http inbounds run in a client。其余的都经过 ControlError:

ControlError FfiError
Invalid Config { message }
Stale Config,以 Stale 的文本作为 message
UnknownGroup UnknownGroup { group }
Failed Io { message }

Config 和 Io 只输出 message,所以移动端 app 看到的是原样的 ApplyError 文本。由于 ApplyError::Stopped 会变成 ControlError::Failed,它到达 app 时是文本为 the supervisor has shut down 的 FfiError::Io,而不是 FfiError::Stopped(见移动端库(etemenanki-ffi))。

  • --test(src/runtime.rs → test_config)先拒绝没有节点的配置(config defines no [[node]] entries),运行 warn_shared_wireguard(多个节点路由到同一个 WireGuard 出站时输出一条 WARN),然后为每个节点构建面板客户端并运行 check_node,在任何错误前加上节点 id。它在 stdout 上输出 Configuration OK,或在 stderr 上输出 configuration error: <e> 并以退出码 1 退出。捕获的输出:configuration error: node 1: outbound wg-egress@v0: wireguard address_family ipv6_only needs an IPv6 address。
  • 配置文件重载(apply_reload)在动任何正在运行的节点之前,对每个新增或修改的节点运行 check_node;[[outbound]] 或 [dns] 变化时则对所有节点运行。拒绝时以 ERROR 级别记录 reload: node <node>: <e>; keeping current config,其中 <node> 是 <panel_type>@<host>#<node_id>,设置了 node type 时后面再加 /<node_type>。见进程运行时与重载。
  • 节点用 Supervisor::apply_with 和 ApplyOptions { allow_disruptive: true } 应用它的 spec(src/manager/node.rs → reconcile),所以 katana 永远不会收到 Disruptive,修改后的 Hysteria 2 混淆会被应用而不是被拒绝。见节点管理器。
  • katana 对 Hysteria 2 入站的 lowering(src/lower/inbound.rs → lower_hysteria)自己保留了三项 supervisor 也会做的检查:通过 Masquerade::new 检查伪装响应(它的注释说,supervisor 会拒绝同样的响应,katana 也自己判断一遍,“so the refusal reads as it always has”,即让拒绝信息保持一贯的样子)、Salamander 密钥长度(obfs_password must be at least 4 bytes for salamander),以及 2 到 600 秒的 UDP 空闲超时(上游的范围)。这些文本不带 inbound 前缀,例如 configuration error: node 1: udp_idle_timeout must be between 2 and 600 seconds 和 configuration error: node 1: obfs_password must be at least 4 bytes for salamander。supervisor 的规则仍在它们之后生效。同一个函数还会拒绝 supervisor 没有规则覆盖的设置:udp_idle_timeout is set but udp is not enabled、obfs_password is set but obfs is not; did you mean obfs = "salamander"?、unknown obfs "<x>" (expected "salamander") 和 unknown hysteria credential kind "<x>" (expected "uuid" or "user_pass")。该文件见入站与出站的 lowering。
  • katana 对用户的 lowering(src/lower/mod.rs → lower_users)会跳过以下用户并输出一条 WARN:凭据无法降为 spec 的用户(skipping user <uid>: <why>)、重复列出的 uid(skipping user <uid>: listed twice),以及凭据已被之前某个用户出示过的用户(skipping user <uid>: shares its credential with user <first>)。凭据归第一个用户,所以共用同一凭据的两个面板用户不会走到 validate_admission。这些原因只指明用户,从不输出凭据。
不变量 原因 由谁固定
每次应用、更新、启动和检查都在规划任何步骤之前先校验 plan 首先调用 validate,而每条路径都调用 plan supervisor/tests/unit/plan.rs → an_invalid_spec_is_refused_without_a_plan
被拒绝的 spec 不改变任何正在运行的东西 校验和 Disruptive 在准备之前就拒绝;准备阶段没有可见的效果,其结果会被丢弃 supervisor/tests/hot_swap.rs → a_refused_spec_changes_nothing:一个未知的路由目标,以及一个同时替换用户集和路由默认出站、但无法绑定端口的 spec,都让 epoch、用户和监听器保持原样
校验不碰文件、socket 或时钟 针对 spec 的纯函数 单元测试在内存中构建每个 spec,TLS 字节可以任意
每条规则单独测试 每个测试在一个其他方面都合法的 spec 中只破坏一条规则;规则有边界时,还检查仍被接受的那个边界值 supervisor/tests/unit/validate.rs(模块注释)
构建代码可以假设 spec 合法 Actor::prepare 中的 expect 调用依赖校验 tag 和负载均衡器相关测试
出站和负载均衡器的 tag 共用一个命名空间;入站和用户集的 tag 不共用 路由以同样的方式指定出站和负载均衡器 a_balancer_may_not_share_an_outbounds_tag、an_inbound_may_share_an_outbounds_tag
spec 中没有空 tag 空 tag 指的是 supervisor 自己的目标 no_tag_may_be_empty
用户编辑与应用遵循相同的准入规则 edit_users 调用 validate_admission 无专门测试
校验文本从不输出密码、UUID 或密钥 用户以 UserId::name() 命名;密钥错误只输出长度。名字可能是账户的用户名(etemenanki-app) 无专门测试
check 与启动在语义和构建上一致 同样的 plan 和 prepare,只有 bind 不同 无专门测试
  • 每次拒绝只报一个错误。 validate 在第一条被违反的规则处返回,prepare 在第一个失败的步骤处返回。一个 spec 有几个问题,就需要尝试几次才能全部找出来。
  • 拒绝时释放什么。 prepare 构建的一切都存放在局部变量和 Prepared 结构体中。出错时它们被丢弃:同一次准备中先前绑定的监听器会再次关闭(a_refused_spec_changes_nothing 先绑定一个备用端口,之后检查它已空闲),这样的绑定创建的 Unix socket 文件由它的 SocketFile guard 删除。用户 key 分配在 UserKeys 的暂存副本上,只有应用提交时才保留。
  • 丢弃应用的 future。 ask 先发送命令,再等待回复。如果调用方在发送仍在等待通道容量时丢弃 future,什么都不会发出。命令一旦进入通道,actor 就会把这次应用执行到底,成功则提交;回复随后发往一个已无人接收的地方,被忽略(let _ = reply.send(...))。
  • check 是调用方任务上的普通 future。 它不 spawn 任何任务,所以丢弃它就会丢弃它构建的一切。
  • 第一个 spec。 SupervisorBuilder::start 在 spawn 采样器、usage sink 或 actor 任务之前应用第一个 spec。第一个 spec 被拒绝时什么也不启动,并返回 ApplyError。在应用任何东西之前,start 会断言采样间隔不为零;间隔为零时以 the sample interval must not be zero panic,而不是返回 ApplyError。
  • 被拒绝的应用留下的日志。 supervisor 不记录拒绝,但准备阶段自己的 INFO 日志会留在日志里:在准备一次随后被拒绝的应用时创建或接管的 TUN 设备,已经记录了 inbound <tag> owns tun device <name> 或 inbound <tag> serves a supplied tun device。
限制 值 位置
每次拒绝报告的错误数 1 validate、prepare
TUN MTU 至少 1280(MIN_TUN_MTU) validate_inbound
Hysteria 2 UDP 空闲超时 设置时为 2 到 600 整秒,含两端(HY2_UDP_IDLE) validate_inbound
Hysteria 2 max_connections、max_circuits 至少 1 validate_inbound
Hysteria 2 客户端 max_concurrent_streams 至少 1 validate_outbound
Salamander 密钥 至少 4 字节(MIN_SALAMANDER_PSK) obfs
伪装响应状态码 100 到 999,233 除外 Masquerade::new
Shadowsocks 2022 服务端密钥和客户端密钥 恰好 16 字节(2022-blake3-aes-128-gcm)或 32 字节(2022-blake3-aes-256-gcm、2022-blake3-chacha20-poly1305) validate_inbound、validate_outbound
Shadowsocks 2022 用户密钥 至少为方法要求的长度;更长的密钥会被折叠 validate_admission
准入用户的 Shadowsocks 2022 入站 只能是 2022-blake3-aes-128-gcm 或 2022-blake3-aes-256-gcm Validator::from_config,构建时
supervisor 命令通道 16 条排队命令 SupervisorBuilder::start

supervisor/tests/unit/validate.rs 被编译进 crate(由 build/validate.rs 通过 #[path] 引入),所以 cargo test -p etemenanki-supervisor --lib 会运行它。它构建一个合法的基础 spec(一个监听 127.0.0.1:1080 的 SOCKS 入站、一个 freedom 出站 direct、系统解析器),然后每次破坏一条规则。

测试 固定的行为
the_base_spec_is_valid 基础 spec 通过
tags_are_unique_within_each_kind 入站、出站、负载均衡器和用户集的 DuplicateTag
a_balancer_may_not_share_an_outbounds_tag 共用的命名空间
an_inbound_may_share_an_outbounds_tag 其他类别各自独立
no_tag_may_be_empty 入站、出站和用户集上的 Invalid
a_route_rule_must_name_a_known_outbound_or_balancer 来自 Route 的 UnknownReference
the_route_default_must_name_a_known_outbound_or_balancer 默认出站的同样情况
a_route_may_name_a_balancer 负载均衡器可以作为路由目标
a_balancer_member_must_be_a_known_outbound 来自负载均衡器的 UnknownReference
a_balancer_member_may_not_be_another_balancer 成员只能是出站
an_inbound_must_name_a_known_user_set 来自入站、kind 为 UserSet 的 UnknownReference
a_balancer_needs_a_member EmptyBalancer
a_balancer_member_needs_an_upstream_a_tcp_probe_can_reach freedom、blackhole、Hysteria 2 和 WireGuard 的 UnprobeableMember
two_inbounds_may_not_share_a_bind 后一个入站不合法
the_same_port_over_udp_is_another_bind TCP 绑定和 UDP 绑定不同
each_protocol_is_served_only_on_its_kind_of_bind 六种被拒绝的配对,三种被接受的配对
an_inbound_carries_tls_material_exactly_when_its_shape_layers_tls 八种 shape 与材料的组合
an_inbound_needs_no_ws_host_or_grpc_authority 入站缺失这些值时被接受
an_outbound_carries_tls_settings_exactly_when_its_shape_layers_tls 六种组合
an_outbound_needs_a_ws_host_and_a_grpc_authority 出站缺失这些值时被拒绝
a_unix_listener_carries_only_the_plain_tcp_shape Unix 绑定上的 TLS 和 WebSocket shape 被拒绝
trojan_vless_and_vmess_need_a_user_set 没有开放模式;指定一个用户集即满足规则
hysteria2_takes_a_shared_password_or_a_user_set_but_not_both 两者都设和两者都不设均被拒绝
a_hysteria2_shared_password_may_not_be_empty 空的共享密码
a_hysteria2_udp_idle_timeout_is_between_2_and_600_seconds 1 和 601 被拒绝,2、600 和 None 被接受
hysteria2_serves_at_least_one_connection_and_one_circuit 0 被拒绝,1 被接受
a_salamander_key_is_at_least_4_bytes_on_either_side 入站和出站都是 3 字节被拒绝、4 字节被接受
a_masquerade_may_not_answer_with_the_success_status 233 被拒绝,404 被接受
a_tun_device_mtu_is_at_least_1280 1279 被拒绝,1280 被接受
socks_over_a_unix_socket_needs_a_udp_bind_to_serve_udp Unix 与 UDP 规则,以及它三种被接受的边界情况
a_shadowsocks_2022_server_key_is_sized_to_its_method 短一个字节或长一个字节都被拒绝
a_wireguard_address_family_needs_an_address_of_that_family 五种地址与地址族的组合
a_hysteria2_client_needs_a_password_and_a_stream 空密码、流数为零
shadowsocks_2022_client_keys_are_sized_to_their_method 用户密钥和一个 identity 密钥
two_users_may_not_present_the_same_credential 共用的 Trojan 密码
a_shared_credential_of_another_kind_is_no_conflict 只看准入所用的类型
hysteria2_usernames_collide_case_insensitively Alice 和 alice 只在 Hysteria 2 上冲突
a_hysteria2_username_may_not_hold_a_colon 只在 Hysteria 2 上
a_hysteria2_password_may_not_be_empty 按密码准入;形如 UUID 的密码被接受
a_shadowsocks_2022_user_key_must_normalise_to_the_method 过短的密钥被拒绝,更长的被折叠后接受

固定本页内容的其他测试:

文件 测试 固定的行为
supervisor/tests/unit/plan.rs an_invalid_spec_is_refused_without_a_plan plan 在规划之前就拒绝
supervisor/tests/hot_swap.rs a_refused_spec_changes_nothing UnknownReference 和 Bind 拒绝让运行中的一切保持原样。那个无法绑定的 spec 还替换了用户集(alice 换成 bob)并路由到 block;之后 alice 仍能连接且走直连,bob 被拒绝,epoch 不变,同一次准备中先前绑定的备用端口也再次空闲
supervisor/tests/hot_swap.rs a_hysteria2_obfuscation_change_needs_allow_disruptive 混淆变更的 Disruptive
supervisor/tests/hot_swap.rs a_tun_device_change_needs_allow_disruptive TUN 变更的 Disruptive(需要 CAP_NET_ADMIN,否则跳过)
protocols/tests/unit/hysteria/server/masquerade.rs the_authentication_success_status_is_refused、a_value_that_is_not_a_status_code_is_refused 两种伪装响应拒绝;0、99、1000 和 65535 被拒绝,599 被接受
protocols/tests/unit/ss_2022/users.rs users_sharing_a_psk_resolve_to_the_first Validator::from_config 的兜底在两个用户共用一个密钥时保留第一个
app/tests/unit/lower.rs semantic_problems_are_left_to_the_supervisor、syntax_errors_are_refused app 与 supervisor 之间的分工
app/tests/unit/subscribe.rs every_lowered_node_builds_and_the_dns_setup_with_it 降为 spec 后的订阅示例能通过 check
katana tests/unit/lower/outbound.rs wireguard_ipv4_only_builds_with_ipv4_address、wireguard_ipv6_only_requires_ipv6_address 用 check 检验 katana 的 WireGuard lowering

没有专门测试的规则:空的负载均衡器 tag、空的 Hysteria 2 账户名或密码(只测试了冒号)、TUN 平台检查、使用 2022-blake3-chacha20-poly1305 并准入用户的 Shadowsocks 2022 入站、用户编辑路径的 no such user set,以及被准入检查拒绝的用户编辑(准入测试直接调用 validate 和 validate_admission,测试套件中唯一的 set_users 调用是成功的)。没有任何 supervisor 测试匹配错误文本;只有 protocols crate 的伪装响应测试和 katana 的 WireGuard 测试匹配了其中的片段。因此改写某条规则的措辞仍能通过测试套件;改写时要同步更新本页和错误信息。如何运行测试套件见测试。