设计原则
源码文件:70 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/concepts/src/lib.rsEtemenanki/concepts/src/core.rsEtemenanki/concepts/src/runtime.rsEtemenanki/concepts/src/buffer.rsEtemenanki/concepts/src/wake.rsEtemenanki/concepts/src/client.rsEtemenanki/concepts/tests/runtime.rsEtemenanki/concepts/tests/client.rsEtemenanki/environment/src/lib.rsEtemenanki/protocols/src/lib.rsEtemenanki/protocols/src/error.rsEtemenanki/protocols/src/helpers/parse.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/core/harness.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/vmess/core.rsEtemenanki/protocols/src/vmess/accounts.rsEtemenanki/protocols/src/ss_2022/core.rsEtemenanki/protocols/src/ss_legacy/core.rsEtemenanki/protocols/src/trojan/core.rsEtemenanki/protocols/src/vless/core.rsEtemenanki/protocols/src/http/core.rsEtemenanki/protocols/src/tun/udp.rsEtemenanki/protocols/src/tun/config.rsEtemenanki/protocols/src/tun/inbound.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/protocols/src/wireguard/device.rsEtemenanki/protocols/src/transports/grpc/stream.rsEtemenanki/protocols/src/hysteria/connection.rsEtemenanki/protocols/src/hysteria/server/config.rsEtemenanki/protocols/src/hysteria/server/inbound.rsEtemenanki/protocols/src/hysteria/server/datagrams.rsEtemenanki/protocols/tests/unit/core/mod.rsEtemenanki/protocols/tests/unit/trojan/core.rsEtemenanki/protocols/tests/unit/vmess/core.rsEtemenanki/protocols/tests/unit/vmess/protocol.rsEtemenanki/protocols/tests/unit/ss_2022/core.rsEtemenanki/protocols/tests/unit/mux/demux.rsEtemenanki/protocols/tests/unit/wireguard/device.rsEtemenanki/protocols/tests/pipeline/wireguard.rsEtemenanki/protocols/tests/unit/mux/frame.rsEtemenanki/protocols/tests/unit/hysteria/protocol.rsEtemenanki/protocols/tests/unit/hysteria/server/datagrams.rsEtemenanki/protocols/tests/unit/transports/grpc_liveness.rsEtemenanki/app/src/config.rsEtemenanki/app/src/transport.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/inbound/tun.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/main.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/outbound/udp_fanout.rsEtemenanki/app/tests/unit/config.rsEtemenanki/app/tests/unit/transport.rsEtemenanki/app/tests/unit/inbound.rsEtemenanki/app/tests/integration/e2e_hysteria.rsEtemenanki/app/tests/integration/e2e_hysteria_inbound.rskatana/src/serve.rskatana/src/connector.rskatana/src/meter.rskatana/src/traffic.rskatana/src/config.rskatana/src/manager/proxy.rskatana/src/manager/transport.rskatana/src/manager/node.rskatana/src/runtime.rskatana/tests/unit/meter.rskatana/tests/unit/traffic.rskatana/tests/unit/runtime.rs
本页汇总了代码库所依据的设计原则。每条原则都说明了设立的原因、负责落实它的类型或检查,以及固定这一行为的测试。最后一节列出维护者对每次改动都会执行的审阅规则,同样以设计原则的形式表述。
修改协议核心、单连接运行时、应用的构建与重载路径,或 katana 的计量逻辑之前,请先读完本页。这些原则大多由类型的形态本身来保证,而不是靠约定。如果某项改动似乎需要绕过其中一条,它通常应该放到别的层去做。
| 原则 | 落实方式 | 固定它的测试 |
|---|---|---|
| 协议核心是 sans-I/O 的 | ProxyCoreDecode::handle 只接收一个 Event 和一个 Effects sink;时钟是构造函数参数 |
core_is_driven_without_any_io、codec_is_driven_without_any_io,以及所有 CoreHarness 测试 |
| 每个连接一个任务 | ProxyServerRuntime 持有传输层、协议核心和所有出站;ProxyClientRuntime 本身是 AsyncRead + AsyncWrite |
server_runtime_relays_through_a_client_runtime_in_one_task、stalled_outbound_holds_uplink_but_not_other_downlink |
| 固定缓冲区,显式上限 | 每个连接三个装箱的 [u8; BUF_SIZE] 数组;用 try_acquire 的信号量和表大小检查 |
frame_larger_than_the_buffer_is_an_error、unknown_or_excess_sessions_are_declined_with_end |
| 错误即事件 | RuntimeError 没有出站相关的变体;出站失败以 Event::ConnectFailed、OutboundError 和 SendFailed 的形式送达 |
connect_failure_reaches_the_core_as_an_event、a_refused_datagram_send_keeps_the_key_alive |
| 配置 fail closed | 每个配置 struct 都带 #[serde(deny_unknown_fields)];决定行为的字符串在构建器里与显式列表匹配 |
a_mistyped_key_is_rejected_rather_than_ignored、an_unknown_security_is_rejected_on_every_network |
| 先构建,再切换 | instance::build 不绑定任何东西;Instance::reload 只在 build 成功后才触碰旧 generation;katana 的 apply_reload 在应用任何改动之前先构建每个新增节点;katana 的 NodeTraffic::prepare / commit |
a_reload_rebinds_the_udp_port,katana 的 a_reload_with_a_node_that_does_not_build_changes_nothing,katana 的 rate_change_drains_old_counter_and_reports_once |
| 测试中的时间是确定的 | deadline 是事件;运行时使用 tokio::time 定时器;katana 的 TokenBucket 读取 tokio::time::Instant |
deadline_is_armed_against_the_tokio_clock、a_chunk_larger_than_the_burst_is_still_limited |
| 解析不会 panic | etemenanki-protocols 和 etemenanki-environment 的 crate 级 #![deny(clippy::…)];helpers::parse::{take, take_array, need_more} |
varint_above_62_bits_is_refused_not_panicked、udp_truncated_in_the_header_is_refused_and_never_panics |
协议核心是 sans-I/O 的
Section titled “协议核心是 sans-I/O 的”协议的服务端是一个状态机:输入字节和事件,输出字节和 effect。它不持有 socket、定时器或任务 Context。concepts/src/core.rs 的模块文档用一句话说明了这条规则:“Neither core touches a socket, a clock or a Context”(两种协议核心都不接触 socket、时钟或 Context)。
原因。 代理协议主要是解析、密码学运算和状态记录。内部没有 I/O,单元测试就可以用手工构造的字节切片调用协议核心,并精确检查它请求了什么。同一个协议核心可以不加修改地运行在 TCP、Unix socket、WebSocket、HTTP/2 流或 QUIC 流之上。所有调度决策(读什么、何时停止读取、何时放弃)都集中在运行时这一个地方,而不是在每个协议模块里重复一遍。
concepts/src/core.rs → ProxyCoreDecode 是服务端协议与系统其余部分之间的全部接口:
pub trait ProxyCoreDecode { type Key: Copy + Ord + Send + Sync + 'static; type Target; type Error; type TransportAddr: Clone + Send + Sync + 'static;
const STAGING_RESERVE: usize; const MAX_DATAGRAM: usize = 4096;
fn handle( &mut self, event: Event<'_, Self>, effects: &mut Effects<'_, Self>, ) -> Result<usize, Self::Error>;
fn held(&self) -> &[u8] { … }}协议核心每次调用接收一个 Event,返回它消费了事件开头的多少字节。它通过向 sink 推入 Effect,以及向传输层方向暂存(stage)回复字节来作出响应:
| 类别 | 变体 |
|---|---|
| 来自传输层的事件 | Transport、TransportDatagram、TransportSendFailed、TransportEof |
| 来自出站的事件 | Outbound、Datagram、SendFailed、OutboundEof、Connected、ConnectFailed、OutboundError |
| 来自定时器的事件 | Deadline |
| 搬运字节的 effect | Forward、SendTo(事件切片中的一段范围);ForwardHeld、SendToHeld(协议核心 held 缓冲区中的一段范围) |
| 作用于出站的 effect | Open、Shutdown、Close |
| 作用于连接的 effect | ShutdownTransport、SetDeadline(Option<Duration>)、Finish |
客户端(ProxyCoreEncodeHandshake、ProxyCoreEncode、ProxyCoreEncodeDatagram)遵循同样的规则:codec 把明文封装进 Staging 区,并就地解开线上的帧,不做任何 I/O。
隐藏输入作为构造函数参数
Section titled “隐藏输入作为构造函数参数”core 模块点名了三种会让协议核心失去确定性的输入:“Randomness, wall-clock time and shared account state are constructor arguments of the concrete core, never something the runtime injects”(随机数、墙上时钟和共享的账户状态是具体协议核心的构造函数参数,绝不由运行时注入)。两个需要检查时间戳的协议核心以普通函数指针的形式接收时钟:
impl<T> VMessCore<T> { pub fn new( validator: Arc<AccountValidator<T>>, now: fn() -> i64, sniff: bool, source: Option<IpAddr>, ) -> Self}impl<T> Ss2022Core<T> { pub fn new( config: Arc<Ss2022ServerConfig<T>>, validator: Option<Arc<Validator<T>>>, sniff: bool, source: Option<IpAddr>, now: fn() -> u64, ) -> Self
pub fn with_system_clock( config: Arc<Ss2022ServerConfig<T>>, validator: Option<Arc<Validator<T>>>, sniff: bool, source: Option<IpAddr>, ) -> Self}app/src/serve.rs 传入真实时钟(vmess::aead::now_unix,或 Ss2022Core::with_system_clock)。测试则传入一个常量:protocols/tests/unit/ss_2022/core.rs 中的 eih_selects_the_user_and_a_stale_timestamp_is_refused 用时钟 || 1_000 构造协议核心,并检查请求因时间戳过期而被拒绝。
共享的账户状态也以同样的方式传入,而它可能有自己的计时。传给 VMessCore::new 的 VMess AccountValidator 依据 std::time::Instant 让重放窗口过期,因此注入的 now 决定时间戳检查的结果,但不决定一个 auth ID 被视为“已见过”的时长。
单个 deadline,由 effect 设置
Section titled “单个 deadline,由 effect 设置”超时也遵循同一规则。协议核心不自己计时:它通过 Effect::SetDeadline 设置运行时唯一的定时器,并对 Event::Deadline 作出反应。concepts/src/core.rs 中的契约写明,当前时间“comes from a clock passed to the constructor, never from Instant::now() inside handle”(来自传给构造函数的时钟,绝不来自 handle 内部的 Instant::now());有多个超时的协议自行维护一个按到期时间排序的映射,设置最早的那个,并在每次 Deadline 时重新设置。
protocols/src/core/mod.rs → Timing 是处理常见情形的共享辅助类型。连接当前的 Phase 决定这唯一一个 deadline 的含义:
stateDiagram-v2 [*] --> Handshake Handshake --> Sniff: 请求已解析,目标为 IP,已开启嗅探 Handshake --> Relay: 请求已解析 Sniff --> Relay: 找到域名、达到上限或 deadline 到期 Relay --> Closing: 一侧已关闭 Relay --> Closing: 空闲 deadline 到期,推入 Finish Closing --> [*]
| 阶段 | 何时设置 deadline | 常量 | 值 |
|---|---|---|---|
Handshake |
仅一次,在第一个字节事件时(Timing::touch) |
HANDSHAKE_TIMEOUT |
10 s |
Sniff |
进入该阶段时(Timing::enter) |
SNIFF_TIMEOUT(protocols/src/sniff/mod.rs) |
300 ms |
Relay、Closing |
每个字节事件都重新设置,因此衡量的是空闲时间而非存活时长 | RELAY_IDLE_TIMEOUT |
300 s |
空闲 deadline 到期时,Timing::expired 自己推入 Effect::Finish 并返回 Expired::Idle。对于 Handshake 和 Sniff,它只报告哪个 deadline 到期,由协议核心决定如何处理。
用 CoreHarness 测试协议核心
Section titled “用 CoreHarness 测试协议核心”protocols/src/core/harness.rs → CoreHarness 在测试中替代运行时。它持有一个 EffectList 和一个 WriteBuffer<HARNESS_STAGING>(HARNESS_STAGING = 64 KiB),不做任何 I/O:
pub struct CoreHarness<C: ProxyCoreDecode> { pub core: C, // effect、staging 区和数据报包列表是私有的}
impl<C: ProxyCoreDecode> CoreHarness<C> { pub fn new(core: C) -> Self pub fn over_datagrams(core: C) -> Self pub fn event(&mut self, event: Event<'_, C>) -> Result<(usize, Vec<Effect<C>>), C::Error> pub fn transport(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error> pub fn feed(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error> pub fn outbound( &mut self, key: C::Key, data: &mut [u8], ) -> Result<(usize, Vec<Effect<C>>), C::Error> pub fn staged(&mut self) -> Vec<u8> pub fn staged_packets(&mut self) -> Vec<(Vec<u8>, C::TransportAddr)> pub fn held(&self, range: std::ops::Range<usize>) -> Vec<u8>}feed 的行为与运行时一致:只要协议核心还有进展,就把未消费的尾部再次交给它,并把 Forward 和 SendTo 的范围换算到调用者的切片上。超时测试不需要时钟,因为 deadline 只是一个事件:
#[test]fn handshake_deadline_fails_the_connection() { let mut h = core(false); let mut partial = vec![0u8; 10]; h.transport(&mut partial).unwrap(); let err = h.event(Event::Deadline).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::TimedOut); let _ = Duration::ZERO;}sans-I/O 的形态也让契约中的微妙之处变得可测。协议核心文档写道 “Never decrypt what you do not consume”(绝不解密你没有消费的字节):就地解密长度头的协议核心必须记住自己已经解密过,因为同样的字节会在下一次调用时再次送来。protocols/tests/unit/vmess/core.rs 中的 a_chunk_split_across_reads_decodes_its_header_once 把一个 VMess 请求截短十个字节,再送入剩余部分,检查 payload 仍然完整输出。
| 测试 | 文件 | 固定的行为 |
|---|---|---|
core_is_driven_without_any_io |
concepts/tests/runtime.rs |
用裸的 EffectList 和 WriteBuffer 驱动一个示例 mux 协议核心;被切开的帧保持未消费。 |
codec_is_driven_without_any_io |
concepts/tests/client.rs |
客户端 codec 契约以同样方式工作。 |
timing_arms_handshake_once_then_idle_per_byte_event |
protocols/tests/unit/core/mod.rs |
Timing 只设置一次握手 deadline,之后在每个字节事件时设置空闲 deadline。 |
a_chunk_split_across_reads_decodes_its_header_once |
protocols/tests/unit/vmess/core.rs |
“绝不重复解密”规则。 |
eih_selects_the_user_and_a_stale_timestamp_is_refused |
protocols/tests/unit/ss_2022/core.rs |
注入的时钟决定时间戳检查的结果。 |
handshake_deadline_fails_the_connection |
protocols/tests/unit/trojan/core.rs |
手动送入的 deadline 让未完成的握手以 TimedOut 失败。 |
每个连接一个任务
Section titled “每个连接一个任务”一个被代理的连接恰好对应一个 future:concepts/src/runtime.rs → ProxyServerRuntime。它持有客户端的传输层、协议核心、connector 以及协议核心打开的每个出站,并亲自搬运每一个字节。没有按方向划分的任务,两半之间也没有 channel。
pub struct ProxyServerRuntime<const BUF_SIZE: usize, Core, Trans, Conn, Mode = ProxyRunsQuiet>where Core: ProxyCoreDecode, Conn: Connector<Core::Target>,{ … }
impl<const BUF_SIZE: usize, Core, T, Conn> ProxyServerRuntime<BUF_SIZE, Core, StreamTransport<T>, Conn, ProxyRunsQuiet>where Core: ProxyCoreDecode, T: AsyncRead + AsyncWrite + Unpin, Conn: Connector<Core::Target>,{ pub fn new(transport: T, core: Core, connector: Conn) -> Self}使用默认的 ProxyRunsQuiet 标记时,运行时是一个 Future,输出为 Result<Traffic, RuntimeError<Core::Error>>。showing_progress() 把它变成一个输出 Traffic 增量的 Stream(ProxyShowsProgress 标记),供需要边传输边统计流量的调用者使用。over_datagrams 基于 DatagramLink 传输而非字节流构建同样的运行时。
上游代理也不会打破这条规则。concepts/src/client.rs → ProxyClientRuntime 在明文一侧实现 AsyncRead + AsyncWrite,并持有线路一侧,因此服务端运行时像轮询其他出站流一样轮询它。它的模块文档写道:服务端协议核心 “forwards plaintext into it with poll_write, reads plaintext back with poll_read, and nothing in between is a task or a channel”(用 poll_write 把明文转发给它,用 poll_read 读回明文,中间既没有任务也没有 channel)。
flowchart LR
client["客户端线路"]
subgraph task["一个任务:ProxyServerRuntime"]
up["up: ReadBuffer"]
core["ProxyCoreDecode"]
staging["staging: WriteBuffer"]
scratch["scratch 缓冲区"]
out1["出站流"]
out2["ProxyClientRuntime"]
end
dest["目标"]
upstream["上游代理"]
client --> up --> core
core -- "Forward" --> out1 --> dest
core -- "Forward" --> out2 --> upstream
out1 -- "读取" --> scratch --> core
core -- "暂存" --> staging --> client
原因。 如果每个方向一个任务、任务之间用 channel 连接,那么每个 channel 都是一个需要有人设上限的队列,每个任务都是一段需要有人结束的生命周期,每个错误都要跨 channel 传给负责处置它的一方。只有一个任务时:
- 背压由结构自然产生。 运行时只在有空间容纳结果时才读取某一侧。当目标不再接受写入时,转发会等待,传输层也就不会被读取。
- 取消即 drop。 drop 这个 future 就会 drop 传输层、所有出站和所有缓冲区。不会留下任何还在运行、需要被找出来停止的东西。
- 顺序是全序的。 effect 按协议核心推入的顺序执行,因此协议核心可以放心地按“先打开,再转发,然后关闭”来推理,不存在竞争。
背压如何工作
Section titled “背压如何工作”concepts/src/runtime.rs 的 # Backpressure 一节说明了调度器遵循的规则:
- effect 严格按顺序执行,一个无法完成的转发会阻住它之后的所有 effect。转发传输层字节时如果在等待,传输层就不会被读取。因此对于多路复用的协议核心,一个停滞的子流会阻住兄弟子流的上行,直到它排空;来自其他出站的下行则照常流动。
- 只有当 staging 区空闲至少
STAGING_RESERVE + 1字节时,才会读取流式出站;只有当 staging 区空闲至少STAGING_RESERVE加上数据报上限时,才会读取数据报出站。客户端停止读取时,下行会在出站 socket 处停下,而且一个包绝不会被读进小于它可能需要的空间。 - held 范围的 effect(
ForwardHeld、SendToHeld)会钉住协议核心的 held 缓冲区。在它执行完毕之前,不会送出任何字节事件。
只有 waker 被触发过的出站才会被轮询。concepts/src/wake.rs → ReadyQueue 和 KeyWaker 记录哪个 key 就绪了,因此拥有许多出站的连接不必在每次唤醒时轮询全部出站。在 Future 模式下,poll 最多执行 WORK_BUDGET(64)个工作单元,然后唤醒自己并返回 Pending,这样 socket 始终就绪的连接也无法独占一个工作线程。
取消即 drop
Section titled “取消即 drop”应用通过 drop 连接的 future 来结束连接。app/src/serve.rs → spawn_scoped 把每个派生出的连接绑定到它所属 generation 的 CancellationToken:
pub fn spawn_scoped<F>(token: CancellationToken, fut: F) -> JoinHandle<()>where F: Future + Send + 'static, F::Output: Send,它在 token.cancelled() 和该 future 之间执行 tokio::select!。generation 被取消时,连接 future 连同它持有的一切一起被 drop。katana 的 src/serve.rs → spawn_scoped(scope: &Scope, fut: F) 在 Scope 下做同样的事;Scope 额外带一个 TaskTracker,使 Scope::shutdown 只在其下所有任务都结束后才返回。
有两种客户端承载连接拥有自己的驱动任务。gRPC 客户端流(protocols/src/transports/grpc/stream.rs → GrpcStream::connect)建立在 HTTP/2 连接之上,这个连接的 future 必须与流分开轮询。Hysteria 2 客户端连接(protocols/src/hysteria/connection.rs)由路由到它的所有 circuit 共享,运行自己的 HTTP/3 驱动;如果服务端声明支持中继 UDP,还会运行一个数据报泵。其上的每个流仍然各自只有一个任务。这两个文件都把这些任务放在 AbortOnDropHandle 中,因此 drop 所有者时驱动也会停止。
katana 在同一个任务内计量
Section titled “katana 在同一个任务内计量”katana 的计费沿用这一模型。src/connector.rs 把交给运行时的每个流式出站都包装成 src/meter.rs → Metered<S>。Gate 把字节计入用户的 UserCounter,并在用户的令牌桶处于欠额时让流暂停:
impl Gate { pub fn new(counter: Arc<UserCounter>, retired: CancellationToken) -> Self pub fn poll_open(&mut self, cx: &mut Context<'_>) -> Poll<io::Result<()>> pub fn sent(&self, n: usize) pub fn received(&self, n: usize)}Metered::poll_read 和 poll_write 会先调用 poll_open。因此限速就是普通的背压:在欠额还清之前它们返回 Pending,运行时随之停止读取另一侧。不存在需要插入计数逻辑的中继循环,也没有每用户额外的任务。一旦用户的租约 token 被取消,同一个 gate 还会以 ConnectionAborted 结束该流。
欠额来自 src/traffic.rs → TokenBucket。每次传输后,Gate::sent 和 Gate::received 用字节数调用 TokenBucket::charge,其文档说明了这条规则:“A charge is always taken in full, even when it is larger than what the bucket holds: the balance goes negative”(扣费总是全额扣除,即使超过桶中现有的量:余额会变为负数)。下一次传输之前,poll_open 通过 ready_at 查询欠额还清的时刻,并休眠到那时。突发量为 rate 的一秒,rate == 0 表示不限速。
| 测试 | 文件 | 固定的行为 |
|---|---|---|
stalled_outbound_holds_uplink_but_not_other_downlink |
concepts/tests/runtime.rs |
停滞的转发会停住上行;另一个 key 的下行仍能到达客户端。 |
datagram_outbound_is_never_truncated_by_staging_backpressure |
concepts/tests/runtime.rs |
只有空间足够时才读取数据报。 |
server_runtime_relays_through_a_client_runtime_in_one_task |
concepts/tests/client.rs |
服务端运行时经由客户端运行时中继,不需要第二个任务。 |
backpressure_from_the_wire_reaches_the_writer |
concepts/tests/client.rs |
缓慢的上游会让明文写入方等待。 |
the_limit_is_shared_by_both_directions |
katana tests/unit/meter.rs |
一个桶同时限制流的两个方向。 |
retiring_the_user_wakes_a_parked_read |
katana tests/unit/meter.rs |
即使流正在等待一个沉默的对端,让用户退役也会结束该流。 |
debt_holds_back_the_next_charge_too |
katana tests/unit/traffic.rs |
对 10,000 B/s 的桶扣费 25,000 字节会留下 1.5 s 的欠额,下一次扣费需等它还清。 |
固定缓冲区与显式上限
Section titled “固定缓冲区与显式上限”每一种单连接资源都有一个在代码中固定、审阅时可见的大小或数量。
缓冲区从不增长
Section titled “缓冲区从不增长”一个 ProxyServerRuntime 一次性分配三个 BUF_SIZE 字节的装箱数组:用于读取传输层的 up: ReadBuffer<BUF_SIZE>,写往传输层的 staging: WriteBuffer<BUF_SIZE>,以及用于读取出站的 scratch: Box<[u8; BUF_SIZE]>。它的文档写道,连接分配的内存 “exactly those plus one small Arc per outbound opened”(恰好是这些,再加上每个已打开出站的一个小 Arc,即该出站的 waker)。concepts/src/buffer.rs 说得更直接:“buffers never grow; backpressure comes from them being full”(缓冲区从不增长;背压来自缓冲区已满)。
由此带来的结果都有代码强制保证:
- 运行时构造函数断言
BUF_SIZE > Core::STAGING_RESERVE(“BUF_SIZE must exceed the core’s STAGING_RESERVE or nothing is ever read”)。 - 放不下的帧是错误,而不会触发重新分配。当未解析区域占满缓冲区而协议核心仍要更多字节时,运行时以
RuntimeError::FrameTooLarge让连接失败。 - 每个协议核心以关联常量
BUF_SIZE声明自己的大小,应用用它实例化运行时,例如drive::<{ TrojanCore::<()>::BUF_SIZE }, _, _>。
| 协议核心 | BUF_SIZE |
源码中给出的理由 |
|---|---|---|
PassthroughCore |
8 KiB | 源码未说明。该协议核心原样中继;TUN 入站用它处理 TCP 流。 |
TunUdpCore |
8 KiB | 一个数据报加上运行时的预留空间。 |
Hy2StreamCore |
8 KiB | 地址和 padding 都取最长时的一个请求。 |
TrojanCore |
16 KiB | 一个 UDP 包帧:包头加上最多 8 KiB。 |
VlessCore |
16 KiB | 长度字段之后最多 MAX_DATAGRAM 字节的一个 UDP 帧。 |
Hy2UdpCore |
16 KiB | 一个重组后的包,加上各分片的包头。 |
ShadowsocksCore |
20 KiB | 一个 MAX_PAYLOAD 大小的 chunk 加上其开销,并留有余量。 |
VMessCore |
32 KiB | 源码未说明。 |
Ss2022Core |
32 KiB | 带满额 padding 的请求 chunk,或对端实际发送大小的一条记录。 |
HttpCore |
MAX_HEAD = 64 KiB |
一个完整的 HTTP 请求头。 |
数量用许可设上限
Section titled “数量用许可设上限”上限要么是信号量,要么是表大小检查。大多数通过 try_acquire_owned 或长度检查来施加:达到上限时,新项目立即被拒绝,而不是在一个自身也需要上限的队列里等待。katana accept 循环中的注释说明了为何拒绝而不排队:在那里等待 “would stop accepting altogether and let the listen backlog absorb the overload instead, which is how a saturated node turns into a silent one”(会完全停止 accept,转而让 listen backlog 吸收过载,一个饱和的节点就是这样变成一个毫无响应的节点的)。
| 上限 | 位置 | 值 | 超出上限时 |
|---|---|---|---|
MAX_LIVE_CONNECTIONS_PER_INBOUND |
app/src/serve.rs |
每个 TCP 或 Unix socket 入站 65,536 | 丢弃已 accept 的 socket。持有的许可随 socket 进入它承载的每一个流。 |
MAX_LIVE_CONNECTIONS_PER_NODE |
katana src/serve.rs |
每个监听器 65,536 | 丢弃已 accept 的 socket。许可在整个连接期间持有,包括它承载的每一个流。 |
MAX_SESSIONS |
protocols/src/mux/demux.rs |
每个 mux 承载连接 256 | 新子流收到 End 回复;承载连接不受影响。 |
DEFAULT_MAX_CONNECTIONS |
protocols/src/hysteria/server/config.rs |
每个监听器 4,096(max_connections) |
拒绝新传入的 QUIC 连接。 |
DEFAULT_MAX_CIRCUITS |
protocols/src/hysteria/server/config.rs |
每个监听器 65,536(max_circuits) |
新代理流以 H3_REQUEST_REJECTED 被重置;新 UDP 关联被丢弃。 |
MAX_SESSIONS |
protocols/src/hysteria/server/datagrams.rs |
每个 QUIC 连接 256 | 会开启新 UDP 关联的包被丢弃。 |
MAX_UDP_SESSIONS |
protocols/src/hysteria/connection.rs |
每个 Hysteria 2 客户端连接 256 | 再开 UDP 会话会以 WouldBlock 失败。 |
MAX_SUBS |
app/src/outbound/udp_fanout.rs |
每个 UDP 关联 64 个子 link(每个路由到的出站一个) | 先关闭最久未发送过数据的子 link。 |
DEFAULT_MAX_FLOWS |
protocols/src/tun/config.rs |
每个设备 65,536(max_flows) |
丢弃新流。 |
存活连接上限是护栏,不是配额。MAX_LIVE_CONNECTIONS_PER_INBOUND 的注释解释了取值:远高于正常流量,同时在计入每个会话的出站 socket 之后,仍低于进程的文件描述符上限。Hysteria 2 入站的两个可配置上限必须至少为 1:构建配置时会拒绝 max_connections = 0 和 max_circuits = 0(“must be at least 1”)。TUN 入站的 max_flows 没有这项检查。
| 测试 | 文件 | 固定的行为 |
|---|---|---|
frame_larger_than_the_buffer_is_an_error |
concepts/tests/runtime.rs |
向 128 字节的运行时送入 500 字节的帧,以 FrameTooLarge 结束。 |
unknown_or_excess_sessions_are_declined_with_end |
protocols/tests/unit/mux/demux.rs |
mux 会话上限只拒绝新会话,不拆除承载连接。 |
the_circuit_limit_refuses_new_sessions |
protocols/tests/unit/hysteria/server/datagrams.rs |
超出 circuit 上限的 UDP 关联被拒绝。 |
stream_permits_are_released_when_a_circuit_ends |
app/tests/integration/e2e_hysteria.rs |
circuit 结束时,Hysteria 2 客户端的流许可会归还,因此在上限为 4 时可以连续跑完 20 个 circuit。该测试用 go 构建上游 Hysteria 服务端,无法构建时提前返回。 |
a_zero_limit_is_refused |
app/tests/unit/inbound.rs |
max_connections 和 max_circuits 必须至少为 1。 |
一个连接有一个客户端,可能有多个出站。如果出站失败是运行时错误,那么一个不可达的目标就会结束 mux 承载连接上的所有子流,协议核心也永远无法用 HTTP 502 或 mux End 回复客户端。因此运行时把出站失败作为事件报告给协议核心,由协议核心决定如何处理。
concepts/src/runtime.rs → RuntimeError 没有表示出站的变体。其文档写道:“Outbound failures are not here: they reach the core as events and it decides.”(出站失败不在这里:它们以事件形式送达协议核心,由协议核心决定。)
pub enum RuntimeError<E> { Transport(io::Error), Core(E), UnknownKey, DuplicateKey, RangeOutOfBounds, BadConsume, FrameTooLarge, WrongLinkKind, StagedWithoutPeer,}Transport 表示客户端一侧失败,Core 表示协议拒绝了客户端,FrameTooLarge 表示一个帧放不进读缓冲区。其余变体表示协议核心违反了契约,例如转发了超出事件切片的范围(RangeOutOfBounds),或在没有对端的情况下向数据报传输层暂存字节(StagedWithoutPeer)。
| 失败 | 送达形式 | 之后的 key |
|---|---|---|
Effect::Open 的拨号失败 |
Event::ConnectFailed { key, error } |
失效 |
| 出站读或写失败 | Event::OutboundError { key, error } |
失效 |
| 数据报出站拒绝了一个包 | Event::SendFailed { key, to, error } |
仍然有效 |
| 数据报传输层拒绝了发往某个对端的一个包 | Event::TransportSendFailed { to, error } |
不适用;传输层继续服务其他对端 |
sequenceDiagram participant C as 客户端 participant R as ProxyServerRuntime participant K as HttpCore participant D as connector C->>R: CONNECT 请求 R->>K: Event::Transport K->>R: Effect::Open R->>D: connect(target) D-->>R: io::Error R->>K: Event::ConnectFailed K->>R: 暂存 RESP_502、ShutdownTransport、Finish R->>C: HTTP 502,然后关闭
客户端一侧为上游代理把这一点做得更精确。ProxyClientConnector 的 connect future 只有在拨号、codec 握手以及握手字节 flush(poll_connected)都完成后才会 resolve。因此服务端协议核心收到的 Event::Connected 意味着上游流确实已经打开;拒绝握手的上游会变成 ConnectFailed,而不是一个在第一个字节时就断掉的中继。
在 etemenanki-protocols 内部,解析器把失败归类为 protocols/src/error.rs → ProtocolError,并以固定的 kind 转换为 io::Error:
ProtocolError 变体 |
io::ErrorKind |
|---|---|
Truncated |
UnexpectedEof |
Unauthenticated |
PermissionDenied |
Malformed、Overflow、Unsupported |
InvalidData |
Crypto、Other |
Other |
Io(e) |
e 的 kind |
need_more 依赖这一映射:它把 UnexpectedEof 转为 Ok(None)(“等待更多字节”),其他错误原样保留。
| 测试 | 文件 |
|---|---|
connect_failure_reaches_the_core_as_an_event |
concepts/tests/runtime.rs |
a_refused_datagram_send_keeps_the_key_alive |
concepts/tests/runtime.rs |
a_refused_transport_packet_is_reported_not_fatal |
concepts/tests/runtime.rs |
upstream_dial_failure_is_connect_failed_not_connected |
concepts/tests/client.rs |
refused_upstream_handshake_is_connect_failed_not_connected |
concepts/tests/client.rs |
配置 fail closed
Section titled “配置 fail closed”配置错误会让程序报错停止,绝不会退回到一个含义不同的默认值。app/src/config.rs 中 InboundConfig::listen 的注释点明了这要防止的故障:listen 键反序列化失败时 “silently falling back to 0.0.0.0 and putting a proxy that was meant to be local on the network”(静默退回 0.0.0.0,把本应只在本机使用的代理暴露到网络上)。
未知的键是错误
Section titled “未知的键是错误”app/src/config.rs 中的每个 struct 都带 #[serde(deny_unknown_fields)]:顶层的 Config、每个配置段,以及每个协议的 settings struct。各协议的 settings 在构建器确定协议之前一直是不透明的 toml::Value;随后 app/src/inbound/mod.rs → parse_settings 把它反序列化为同样拒绝未知字段的 struct。katana 的 src/config.rs 中每个 struct 也带同样的属性。
$ etemenanki-app --test -c config.tomlERROR etemenanki_app: configuration invalid: TOML parse error at line 4, column 1 |4 | lisen = "0.0.0.0" | ^^^^^unknown field `lisen`, expected one of `tag`, `protocol`, `listen`, `port`, `stream`, `address_family`, `sniffing`, `settings`缺少 listen 时绑定的是 127.0.0.1,而不是所有网卡。注释给出了理由:deny_unknown_fields 能捕获拼写错误,“and the loopback default catches whatever it does not: a server that wants every interface says so.”(loopback 默认值兜住它漏掉的一切:想监听所有网卡的服务器应当明确写出来。)
未知的值是错误
Section titled “未知的值是错误”决定行为的字符串在构建器中与显式列表匹配,其他任何值都是错误。app/src/transport.rs 展示了这种模式:
pub fn tls_layer(network: &str, security: Option<&str>, ctx: &str) -> io::Result<bool>pub fn resolve_stream(stream: &StreamConfig, ctx: &str) -> io::Result<StreamShape>pub fn reject_stream_settings(stream: &StreamConfig, proto: &str, ctx: &str) -> io::Result<()>tls_layer 结合 network 校验 security,而不是拿它与 "tls" 比较、不相等就退回明文。它的注释解释了这为何对代理尤其重要:如果误建了明文传输,唯一的症状是握手失败,而此时凭据已经以明文穿过网络。reject_stream_settings 处理与之相关的情形:在一个从不读取 [stream] 块的协议上写了这个块(SOCKS、Shadowsocks、Hysteria 2 和 TUN 入站,以及任何 Unix socket 上的入站;freedom、blackhole、hysteria2 和 wireguard 出站),否则它会被一声不响地丢弃。
下面每一项都用 etemenanki-app --test 验证过:
| 错误 | 报错 |
|---|---|
lisen = "0.0.0.0" |
unknown field `lisen`, expected one of … |
protocol = "sock" |
inbound in: unknown protocol "sock" |
security = "tsl" |
inbound in: unknown stream security "tsl" (expected "tls" or "none") |
network = "tcp" 搭配 security = "tls" |
inbound in: security = "tls" is not valid with network = "tcp"; use network = "tls" for TLS over plain TCP (…) |
SOCKS 入站上的 network = "ws" |
inbound in: protocol socks does not support stream network "ws" |
SOCKS settings 中的 udpp = 1 |
inbound in: invalid settings: unknown field `udpp`, expected one of `auth`, `accounts`, `udp`, `udp_bind` |
路由规则中的 network = "tpc" |
invalid rule network "tpc" (expected "tcp" or "udp") |
domain_regex = ["("] |
invalid domain regex "(": regex parse error: … |
port = ["2000-1000"] |
invalid port spec: "2000-1000" has a lower bound above its upper bound |
cidr = ["10.0.0.0/33"] |
invalid cidr "10.0.0.0/33": … |
路由规则中的 outbound = "direkt" |
route references unknown outbound tag: direkt |
--test 会运行 config::load,再运行与真实启动相同的 instance::build,因此它拒绝的恰好就是启动时会拒绝的内容,而且不绑定任何东西。
| 测试 | 文件 |
|---|---|
a_mistyped_key_is_rejected_rather_than_ignored |
app/tests/unit/config.rs |
an_inbound_defaults_to_loopback |
app/tests/unit/config.rs |
an_unknown_security_is_rejected_on_every_network |
app/tests/unit/transport.rs |
tcp_with_tls_is_rejected_and_names_the_fix |
app/tests/unit/transport.rs |
a_transport_the_protocol_cannot_honour_is_rejected |
app/tests/unit/transport.rs |
an_unknown_setting_is_refused |
app/tests/unit/inbound.rs |
先构建,再切换
Section titled “先构建,再切换”新配置在触碰任何正在服务流量的东西之前,会先被解析、校验并完整构建。app/src/instance.rs → build 产出一个 Built 值(路由器、每个入站及其 BindSpec、负载均衡器和解析器),并且不绑定任何东西:
pub fn build(cfg: &Config) -> io::Result<Built>Instance::reload 把它用作一道闸门:
flowchart TB
read["读取文件"] --> same{"字节未变?"}
same -- "是" --> stop["返回"]
same -- "否" --> parse["config::parse_bytes"]
parse -- "错误" --> keep["记录日志,保留旧 generation"]
parse --> build["instance::build"]
build -- "错误" --> keep
build --> cancel["取消旧 generation,等待其 accept 循环结束"]
cancel --> spawn["spawn_generation(built, false)"]
解析或构建失败的配置会被记录到日志,旧 generation 继续运行,不受任何影响。由于每个入站的 BindSpec(TCP、UDP、Unix 路径或 TUN 设备)都在 build 内确定,--test、启动和重载执行的是同一套校验。只有绑定本身发生在 build 之后。
自己持有句柄的入站会在下一个 generation 绑定之前释放句柄。app/src/serve.rs → run_hysteria_inbound 在其 token 被取消后等待 Hy2Inbound::shutdown,因为连接仍存活时,drop 一个 QUIC endpoint 并不会释放它的 UDP 端口。其注释称这是 “what makes a reload transactional for an inbound that owns its handle”(让持有自身句柄的入站的重载具备事务性的关键)。
katana 在三处采用了同样的拆分:
- 配置重载。
src/runtime.rs→apply_reload在应用任何改动之前,先构建新的出站池(如果它有变化)、本次重载新增的每个节点,以及条目有变化的每个运行中节点的面板客户端。只要其中任何一个构建失败,它就记录 “keeping current config” 并返回:不会移除任何节点,同时进行的其他修改也都不会被应用。之后,条目有变化的运行中节点会以StaticUpdate::Config的形式收到新条目,src/manager/node.rs→NodeManager::apply_static以 “whole or not at all”(要么全部生效,要么完全不生效)的方式接受它:在保存任何东西之前先构建新的面板客户端和路由器,并拒绝无法构建的修改(“config edit refused, keeping the running one”)。 - 监听器启动。
src/manager/transport.rs在绑定之前先构建传输层、协议表或 Hysteria 2 服务端:“Everything that can fail happens before the bind, so a bad config never half-binds”(所有可能失败的步骤都发生在绑定之前,因此错误的配置绝不会只绑定一半)。 - 用户刷新。
src/manager/proxy.rs→ProxyManager::refresh先构建替换用的用户表。构建错误会被记录(“proxy refresh build failed, keeping current”),并且 “leaves the running state entirely untouched”(运行中的状态完全不受影响)。之后它才发布用户注册表并切换这些表。
用户注册表本身在 src/traffic.rs 中分为两个阶段:
impl NodeTraffic { pub fn prepare(&self, entries: Vec<UserEntry>) -> PreparedUsers pub fn commit(&self, prepared: PreparedUsers)}prepare 暂存下一组用户,不修改注册表,也不读取任何字节总量,因此下一组表可以在任何东西改变之前构建完成(也可以构建失败)。commit 一步完成切换,并把每个被换下的计数器停放为 draining 状态。它按与 snapshot 相同的顺序同时获取注册表锁和 draining 锁,因此 snapshot 看到的每个被换下的计数器都恰好只在一个位置,“never both, which would report its bytes twice and then commit them twice”(绝不会两处都有,否则它的字节会被上报两次、提交两次)。
| 测试 | 文件 |
|---|---|
a_reload_rebinds_the_udp_port |
app/tests/integration/e2e_hysteria_inbound.rs |
a_reload_with_a_node_that_does_not_build_changes_nothing |
katana tests/unit/runtime.rs |
rate_change_drains_old_counter_and_reports_once |
katana tests/unit/traffic.rs |
rebound_credential_reports_the_old_uid_separately |
katana tests/unit/traffic.rs |
测试中的时间是确定的
Section titled “测试中的时间是确定的”代理的超时以秒甚至分钟计:10 s 的握手上限、300 s 的空闲上限、一秒的令牌桶。靠睡眠熬过这些时间的测试既慢又不稳定。代码通过三种方式避免真实的睡眠:
- deadline 是事件。 协议核心测试手动送入
Event::Deadline,如上文的handshake_deadline_fails_the_connection。完全不涉及时钟。 - 运行时使用 tokio 定时器。
ProxyServerRuntime::set_deadline计算tokio::time::Instant::now() + after并使用tokio::time::sleep_until,从不使用std::time。其注释给出了原因:“so a paused test clock (the only way to test a minutes-long idle timeout) is honoured”(这样暂停的测试时钟才会生效,而这是测试长达数分钟的空闲超时的唯一办法)。标记为#[tokio::test(start_paused = true)]的测试就能瞬间且精确地推进时间。 - 墙上时钟通过注入获得。 时间戳检查在构造函数中接收
now: fn() -> …,如上文所示。
katana 的 TokenBucket 同样读取 tokio::time::Instant,因此它的测试可以断言精确的时长:
#[tokio::test(start_paused = true)]async fn a_chunk_larger_than_the_burst_is_still_limited() { let b = TokenBucket::new(10_000); let start = tokio::time::Instant::now(); b.consume(30_000).await; // 10 000 came out of the burst; the other 20 000 take two seconds. assert_eq!(start.elapsed(), Duration::from_secs(2));}| 测试 | 文件 | 时钟 |
|---|---|---|
deadline_is_armed_against_the_tokio_clock |
concepts/tests/runtime.rs |
暂停;先推进一小时,因此如果 deadline 基于 std::time 设置,就会在错误的时刻触发 |
deadline_event_lets_the_core_time_out |
concepts/tests/runtime.rs |
暂停 |
liveness_restarts_the_idle_deadline_on_progress |
protocols/tests/unit/transports/grpc_liveness.rs |
暂停 |
handshake_deadline_fails_the_connection |
protocols/tests/unit/trojan/core.rs |
无;deadline 是事件 |
an_idle_bucket_banks_one_second_and_no_more |
katana tests/unit/traffic.rs |
暂停 |
the_limit_holds_however_the_writes_are_sized |
katana tests/unit/meter.rs |
暂停 |
解析不会 panic
Section titled “解析不会 panic”协议解析器读取的每个字节都来自不可信的对端。解析器中的 panic 会杀死连接的任务,而用攻击者控制的长度计算出的切片下标正是 panic 的来源。因此解析线上数据的库 crate 在编译期禁止会 panic 的写法。protocols/src/lib.rs 和 environment/src/lib.rs 都在 crate 根部声明:
#![deny( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::arithmetic_side_effects)]紧随其后的 #![cfg_attr(test, allow(…))] 为测试代码解除这一限制,因为测试处理的是已知合法的输入。etemenanki-concepts 和 etemenanki-app 没有这些 crate 级 deny。维护者在改动合入前执行的验证门槛是 cargo clippy --workspace --all-targets --all-features -- -D warnings,因此其他任何 clippy 警告同样会让改动不通过。
protocols/src/helpers/parse.rs 提供了替代写法:
pub fn take<'a, I>(data: &'a [u8], index: I, what: &'static str) -> Result<&'a [u8], ProtocolError>where I: SliceIndex<[u8], Output = [u8]>,
pub fn take_array<const N: usize>(data: &[u8], at: usize) -> Result<[u8; N], ProtocolError>
pub fn need_more<T>(result: std::io::Result<T>) -> std::io::Result<Option<T>>take在切片越界时返回ProtocolError::Truncated(what),而不是 panic。take_array检查偏移量加法(Overflow("field offset"))和边界,用于u16::from_be_bytes之类的读取。need_more区分“缓冲区还不够长”和“输入格式错误”,让拿到的是逐渐增长的缓冲区的解析器可以请求稍后再被调用。
| 测试 | 文件 |
|---|---|
varint_above_62_bits_is_refused_not_panicked |
protocols/tests/unit/hysteria/protocol.rs |
varint_truncated_at_every_boundary |
protocols/tests/unit/hysteria/protocol.rs |
tcp_response_truncated_at_every_boundary |
protocols/tests/unit/hysteria/protocol.rs |
udp_truncated_in_the_header_is_refused_and_never_panics |
protocols/tests/unit/hysteria/protocol.rs |
rejects_truncated_metadata |
protocols/tests/unit/mux/frame.rs |
维护者会用下面的规则检查每一次改动。每条规则描述的是代码必须做到什么,并不表示当前代码中哪些部分已经做到。触及某条规则所覆盖领域的改动,应附带针对该规则的测试;如果规则禁止某种行为,还应包含负向测试。
UDP 关联只接受自己的客户端
Section titled “UDP 关联只接受自己的客户端”UDP 关联(SOCKS 的 UDP ASSOCIATE,或任何把 UDP 端点交给客户端的中继)只接受来自建立它的那个客户端的数据报,并对每个包都做这项检查。在客户端一侧,link 只接受来自与之通信的中继的回复,绝不把其他来源的数据报当作中继回复。否则,任何能访问中继端口的主机都能向别人的流中注入数据包。
这方面的改动需要一个测试:由第三方地址向中继发包,并确认该包被丢弃。
单连接队列有上限,压力能传回生产者
Section titled “单连接队列有上限,压力能传回生产者”每个为单个连接保存数据的队列都有明确的容量。把元素从有界 channel 搬进无界队列不算数:这等于去掉了 channel 本应提供的上限。消费者停下时,压力会传回数据的生产者,内存不会增长。在单任务运行时中,固定缓冲区本身就是这个上限;任何额外引入自己队列的组件,都需要为它设定自己的上限。
WireGuard 驱动(protocols/src/wireguard/device.rs)就是一个具体的例子。一个驱动任务服务隧道中的所有连接,每个连接通过一个容量为 CHANNEL_CAP(256)个元素的有界 channel 把应用数据交给它。驱动在每个连接的 smoltcp socket 之前最多暂存一个元素,并且只在没有暂存元素时才读取该连接的 channel。当远端或隧道不再消费某个连接的数据时,先是它的 socket 被填满,然后是它的 channel,接着它的写入方开始等待;驱动不会再排队任何数据,其他连接照常流动。在下行方向,驱动只在该连接通往应用的 channel 还有空间时才读取 socket。
| 测试 | 文件 | 固定的行为 |
|---|---|---|
a_held_item_keeps_the_channel_unread |
protocols/tests/unit/wireguard/device.rs |
驱动暂存着一个元素时不会读取 channel,因此 channel 被填满,生产者的下一次发送以 Full 失败。 |
a_stalled_tcp_flow_blocks_its_writer |
protocols/tests/pipeline/wireguard.rs |
远端什么都不读的流,其写入方最多写入 512 KiB 后就会阻塞;经同一隧道的另一个流仍能流动;远端恢复读取后,等待中的写入得以完成。 |
活动连接上限覆盖整个中继过程
Section titled “活动连接上限覆盖整个中继过程”被描述为限制活动连接的信号量或许可,要从准入开始一直持有到中继结束。只覆盖握手、解码或传输阶段的许可,限制的只是那个阶段,而不是活动连接。在每个 spawn 边界处,许可都必须移入运行中继的任务,并且不能在中继开始前被 drop。针对这类上限的测试要检查两方面:中继运行期间许可确实起到限制作用,中继结束时许可会被释放。
发往多地址域名的 UDP 选择可用的地址族
Section titled “发往多地址域名的 UDP 选择可用的地址族”当 DNS 为某个 UDP 目标返回多个地址时,代码要选择一个地址族(IPv4 或 IPv6)与实际可用 socket 相匹配的地址。只取第一个结果,并因该地址族没有 socket 而丢包,就忽略了本可以使用的地址。
协议固定字段严格校验
Section titled “协议固定字段严格校验”版本字节、保留字节、长度、命令和类型码都要与协议定义的值比对,其他任何值都是错误。不能假定对端是正确的实现。修改解析器时,应附带负向测试,为每个固定字段送入协议未定义的值。
配置拼写错误 fail closed
Section titled “配置拼写错误 fail closed”稳定的配置结构拒绝未知字段。这对 listen、port、protocol、network、security、TLS 设置、出站和路由尤为重要。决定行为的设置出现未知值时应报错:无法识别的 security 值绝不能因为“不是 tls”而变成明文。
重载是事务性的
Section titled “重载是事务性的”重载不能先销毁正在运行的 generation,然后才发现新的 generation 无法启动。预期的顺序是:
- 解析、校验并构建新配置;
- 尽可能提前创建新资源;
- 确认新的 generation 可以工作;
- 切换 generation;
- 失败时保留或恢复旧 generation。
失败的重载还需要在外部依赖恢复后有办法重试。只等待配置文件内容发生变化是不够的。
katana 计费绝不丢失或重复提交计数
Section titled “katana 计费绝不丢失或重复提交计数”流量计数器保持以下性质:
- 上报失败不会丢失字节;
- 重试上报不会重复计费;
- 已离开用户的字节(残余量)可以恢复并在之后上报;
- 用户被移除后又重新加入时的行为有明确定义;
- 上报进行期间新增的字节不会被该次上报提交。
提交成功的上报会减去它上报的数量,而不是把计数器重置为零;失败的上报会把它的行交还回去,由下一次上报重试。
速率限制按实际字节数计入欠额
Section titled “速率限制按实际字节数计入欠额”令牌桶按实际传输的字节数扣费。大于桶容量的 chunk 会被全额扣费,桶进入欠额状态;这个 chunk 不会在一个补充周期后就被放行。实际速率不取决于中继每次读写的大小。
日志绝不包含凭据
Section titled “日志绝不包含凭据”日志行中不包含密码、UUID、token、密钥、面板 secret,或查询字符串中带有 secret 的完整 URL。凭据格式错误时,日志只给出安全的标识符和错误类型,绝不输出原值。修改 HTTP 错误处理时,要保持 URL 和 token 的脱敏。