跳转到内容

mux.cool 与 XUDP

源码文件:26 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/mux/mod.rs
  • Etemenanki/protocols/src/mux/frame.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/error.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/trojan/core.rs
  • Etemenanki/protocols/src/trojan/protocol.rs
  • Etemenanki/protocols/src/vless/core.rs
  • Etemenanki/protocols/src/vless/protocol.rs
  • Etemenanki/protocols/src/vmess/core.rs
  • Etemenanki/protocols/src/vmess/protocol.rs
  • Etemenanki/protocols/src/vmess/framing.rs
  • Etemenanki/concepts/src/core.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/outbound/udp_fanout.rs
  • Etemenanki/protocols/tests/unit/mux/frame.rs
  • Etemenanki/protocols/tests/unit/mux/demux.rs
  • Etemenanki/protocols/tests/unit/vless/protocol.rs
  • Etemenanki/concepts/tests/runtime.rs
  • Etemenanki/app/tests/integration/e2e_xray_mux.rs
  • katana/src/connector.rs
  • katana/src/serve.rs

mux.cool 是 Xray 的多路复用层。一条代理连接,即承载连接(carrier),可以容纳许多相互独立的会话,每个会话是一条 TCP 流或一个 UDP 关联。每一段会话数据都放在一个小帧里传输,帧中写明它所属的会话。XUDP 是它的 UDP 扩展:UDP 会话的每个数据包都可以指定自己的对端,因此一个会话可以与多个地址通信。模块 etemenanki_protocols::mux 移植了 Xray common/mux 的服务端部分。它的核心是一个 sans-I/O 解复用器 Demux,Trojan、VLESS 和 VMess 服务端协议核心一旦发现请求是承载连接,就切换到它。

本页面向修改 protocols/src/mux/ 或这三个协议核心中承载连接路径的贡献者。内容包括:各协议如何标识承载连接、帧布局、会话表及其 key generation、上行帧如何在不复制的前提下变成运行时 effect、跨 VMess 数据块的帧如何被 held、下行字节如何分帧并预留空间,以及固定每条规则的测试。本页描述的内容都不可配置:每个 Trojan、VLESS 和 VMess 入站都接受承载连接,没有任何设置可以关闭它。唯一会作用到子流的入站设置是嗅探。这三个协议的入站设置见各自的用户指南页。

仅服务端 本 crate 只对收到的承载连接做解复用,从不对自己的出站流量做多路复用:没有 mux 客户端。

关注点 位置 作用
承载连接地址 protocols/src/mux/mod.rs → MUX_ADDRESS、mux_destination、is_mux_destination 伪目标 v1.mux.cool,以及 Trojan 用来识别承载连接的检查
帧 codec protocols/src/mux/frame.rs → parse_meta、parse_frame、encode_keep、encode_end 及其 async 和 staging 变体 解析和构建帧,并强制执行元数据和数据的长度上限
会话表 protocols/src/mux/demux.rs → Demux 把线上的会话 id 映射为出站 key,打开和退役子流,并拒绝超额或未知的会话
上行分派 Demux::feed、Demux::feed_chunks 把帧转换为 Effect::Open、Forward、ForwardHeld、SendTo、SendToHeld 和 Shutdown
下行分帧 Demux::on_outbound、on_datagram、on_outbound_gone、on_transport_eof 把出站字节和数据包封装为 Keep 帧,把已结束的子流封装为 End
出站 key protocols/src/core/mod.rs → FlowKey、SubKey 三个承载连接协议核心交给运行时的 key 类型
承载连接识别与 staging TrojanCore、VlessCore、VMessCore 进入 Mux 状态,向解复用器喂数据,并在声明的 staging 预留量内暂存或加密其输出

本模块交给其他部分的事情:

  • 路由和拨号。 每个子流通过 Effect::Open 作为独立的 Flow 到达程序的 connector(连接器)。在 etemenanki-app 中,AppConnector 对流子流只路由一次,而把 UDP 子流变成按数据包逐个路由的 FanOutLink。katana 驱动的是同样的三个协议核心,因此每个 mux 子流也会各自经过其 connector 的准入、路由、审计和流量计量。
  • I/O 和背压。 per-connection 运行时执行 effect,在转发挂起期间保留读缓冲区,并保证在投递事件之前有足够的 staging 空间。
  • 承载连接的加密与传输。 VMess 自己打开和加密它的数据块流。Trojan 和 VLESS 依靠其下层的传输(TLS、WebSocket、gRPC)提供机密性。

三个协议用两种方式标识多路复用。VLESS 和 VMess 有 mux 命令。Trojan 没有,所以客户端通过地址来标识。

承载连接 标识方式 承载连接的 Flow::destination 立即暂存
VLESS 命令字节 CMD_MUX(0x03)。请求头在命令字节处结束,没有地址 mux_destination():TCP、v1.mux.cool、端口 0 RESPONSE_HEADER
VMess 加密请求头中的命令 0x03,没有地址 mux_destination() 经 VMessCore::reply 加密的响应头
Trojan 命令不是 CMD_UDP_ASSOCIATE(0x03)的请求(实际上就是 CMD_TCP_CONNECT(0x01)),且地址满足 is_mux_destination 客户端发来的原地址,例如 Xray 发送的 v1.mux.cool 端口 9527 无,因为 Trojan 从不应答
protocols/src/mux/mod.rs
pub const MUX_ADDRESS: &str = "v1.mux.cool";
pub fn mux_destination() -> Destination
pub const MUX_PORT: u16 = 9527;
pub fn is_mux_destination(dest: &Destination) -> bool

is_mux_destination 匹配与 MUX_ADDRESS 相等(忽略 ASCII 大小写)的 Remote::Domain,并且忽略端口,与 Xray 的 mux.Server.Dispatch 一致。MUX_PORT 仅供参考。只有 TrojanCore 会调用这项检查,而且只针对非 CMD_UDP_ASSOCIATE 的请求。SOCKS、HTTP 或 Shadowsocks 对 v1.mux.cool 的 CONNECT 会像其他任何域名一样被拨号。VLESS 和 VMess 的请求头解析器会用 mux_destination() 代替缺失的地址。Command::network 把 Mux 映射为 DialNetwork::Tcp,因为承载连接本身是一条流。

在承载连接上,协议核心照常构建其 Flow(用户、来源、目标),并把它传给 Demux::new。这个 flow 从不交给 connector,它只是每个子流的模板:

protocols/src/flow.rs
impl<T> Flow<T> {
pub fn toward(&self, destination: Destination) -> Self
}

toward 复制承载连接的 user 和 source,设置子流自己的目标,并把 sniffed 重置为 None。因此每个子流都归属于认证了该承载连接的用户。

识别之后,每个协议核心设置 State::Mux(Demux::new(flow, self.sniff)) 并进入 Phase::Relay,这会启用 RELAY_IDLE_TIMEOUT(300 秒)。这一个空闲截止时间覆盖整个承载连接。Timing::touch 在每个传输层和出站字节事件上重新设置它,所以一个 KeepAlive 帧就能让安静的承载连接保持打开。截止时间到达后,Timing::expired 推送 Effect::Finish,承载连接连同其所有子流一起结束。子流没有自己的空闲计时器。

一个帧由带长度前缀的元数据块,以及可选的、带长度前缀的数据块组成。所有整数都是大端序。

字段 大小(字节) 含义
元数据长度 2 其后元数据块的长度。至少为 MIN_META_LEN(4),至多为 MAX_META_LEN(512)
会话 id 2 此帧所属的子流,由客户端选定
状态 1 0x01 New、0x02 Keep、0x03 End、0x04 KeepAlive
选项 1 位 OPTION_DATA(0x01):其后跟有数据块。位 OPTION_ERROR(0x02):对端报告该会话出错
网络 1 每个 New 帧都有;Keep 帧上只有当这个字节为 NETWORK_UDP 时才存在。0x01 TCP,0x02 UDP
地址 5 到 259 目标(New)或逐包对端(UDP Keep),采用 AddressCodec::VMESS 布局:2 字节端口,然后是类型 0x01 IPv4(4 字节)、0x02 域名(1 个长度字节加至多 255 字节)或 0x03 IPv6(16 字节)
全局 id 8 XUDP 会话标识。仅在打开 UDP 会话、设置了 OPTION_DATA 的 New 帧上读取,且元数据中至少还剩 8 字节
元数据其余部分 可变 忽略。元数据长度已把它计算在内
数据长度 2 仅在设置 OPTION_DATA 时存在。至多为 MAX_DATA_LEN(8192)
数据 数据长度 子流的载荷:流字节,或一个 UDP 数据包

地址 codec 与 VLESS 和 VMess 使用的相同(frame::ADDR 即 AddressCodec::VMESS),端口在前。AddressCodec::MAX_LEN 为 259:类型字节、长度字节、255 字节的域名以及端口。

流会话上的 Keep 帧不带网络字节和地址。parse_meta 会先查看选项之后的那个字节,只有它是 NETWORK_UDP 时才解析地址,与上游做法一致,因为在流会话上元数据在选项字节处就结束了。

protocols/src/mux/frame.rs
pub const ADDR: AddressCodec = AddressCodec::VMESS;
pub const STATUS_NEW: u8 = 0x01;
pub const STATUS_KEEP: u8 = 0x02;
pub const STATUS_END: u8 = 0x03;
pub const STATUS_KEEP_ALIVE: u8 = 0x04;
pub const OPTION_DATA: u8 = 0x01;
pub const OPTION_ERROR: u8 = 0x02;
pub const NETWORK_TCP: u8 = 0x01;
pub const NETWORK_UDP: u8 = 0x02;
pub const MAX_META_LEN: usize = 512;
pub const MAX_DATA_LEN: usize = 8 * 1024;
const MIN_META_LEN: usize = 4;
pub const FRAME_OVERHEAD_MAX: usize = 2 + MIN_META_LEN + 1 + AddressCodec::MAX_LEN + 2;
const GLOBAL_ID_LEN: usize = 8;

MAX_META_LEN 对应 frame.go 中 metaLen > 512 的拒绝逻辑,并限制了不可信对端能引起的单帧内存分配。MAX_DATA_LEN 对应上游写入端按 8 KiB 切分的做法。因此最大的上行帧为 2 + 512 + 2 + 8192 = 8708 字节,能放进 Trojan 和 VLESS 运行时 16 KiB 的读缓冲区。FRAME_OVERHEAD_MAX 为 268:服务端发出的帧在载荷之外最多增加的字节数(一个带 UDP 对端和域名地址的 Keep)。

protocols/src/mux/frame.rs
pub enum SessionStatus {
New,
Keep,
End,
KeepAlive,
}
pub struct FrameMeta {
pub session_id: u16,
pub status: SessionStatus,
pub option: u8,
pub target: Option<Destination>,
pub global_id: Option<[u8; GLOBAL_ID_LEN]>,
}
impl FrameMeta {
pub fn has_data(&self) -> bool
}
pub struct Frame {
pub meta: FrameMeta,
pub data: Option<Range<usize>>,
pub consumed: usize,
}

FrameMeta::target 在 New 帧上是目标,在 UDP Keep 帧上是逐包对端,其他情况下为 None。Frame::data 是载荷在被解析缓冲区中的区间,而不是副本;consumed 是整个帧的长度。

protocols/src/mux/frame.rs
pub fn parse_meta(b: &[u8]) -> io::Result<FrameMeta>
pub fn parse_frame(buf: &[u8]) -> io::Result<Option<Frame>>
pub async fn read_meta<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<FrameMeta>
pub async fn read_data<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<Bytes>
pub fn encode_keep(session_id: u16, udp_peer: Option<&Destination>, payload: &[u8]) -> Bytes
pub fn encode_end(session_id: u16) -> Bytes
pub fn encode_keep_into(
session_id: u16,
udp_peer: Option<&Destination>,
payload: &[u8],
out: &mut Staging<'_>,
) -> Option<()>
pub fn encode_end_into(session_id: u16, out: &mut Staging<'_>) -> Option<()>
  • parse_frame 是解复用器使用的 sans-I/O 解析器。缓冲区只含一个帧的一部分时,它返回 Ok(None)。两个长度字段一旦可见就立即检查,不等帧体到齐,因此超长的长度会立刻失败,而不会让调用方缓冲多达 64 KiB 的数据。
  • read_meta 和 read_data 是对应的 async 版本,上限相同。服务端路径不使用它们,单元测试用它们把帧读回来。
  • encode_keep 和 encode_end 构建服务端到客户端的帧。服务端从不发出 New 或 KeepAlive:上游的 NewResponseWriter 以后续模式开始,所以每个响应都是 Keep,关闭会话用的是选项为 0 的 End。encode_keep 以 u16 写入载荷长度,所以调用方要保证载荷不超过 MAX_DATA_LEN。
  • encode_keep_into 和 encode_end_into 把同样的字节直接写入 Staging 区域。空间不足或载荷长度超出 u16 时,它们返回 None,并且什么也不暂存。在此版本中只有单元测试调用它们;Demux 用 encode_keep 和 encode_end 构建下行数据。
protocols/src/mux/demux.rs
pub const MAX_SESSIONS: usize = 256;
pub const fn downlink_overhead(read_size: usize) -> usize
struct Sub {
key: SubKey,
peer: Option<Destination>,
}
enum Origin {
Slice(usize),
Held(usize),
}
pub struct Demux<T> {
sessions: BTreeMap<u16, Sub>,
generation: u32,
straddle: Vec<u8>,
partial_at: usize,
out: Vec<u8>,
flow: Flow<T>,
sniff: bool,
}
impl<T> Demux<T> {
pub fn new(flow: Flow<T>, sniff: bool) -> Self
pub fn held(&self) -> &[u8]
pub fn out(&self) -> &[u8]
pub fn keys(&self) -> impl Iterator<Item = FlowKey> + '_
pub fn is_empty(&self) -> bool
}
impl<T: Send + Sync + 'static> Demux<T> {
pub fn feed<C>(
&mut self,
plain: &[u8],
base: usize,
fx: &mut Effects<'_, C>,
) -> io::Result<usize>
where
C: ProxyCoreDecode<Key = FlowKey, Target = Flow<T>>;
pub fn feed_chunks<C>(
&mut self,
data: &[u8],
chunks: &[std::ops::Range<usize>],
fx: &mut Effects<'_, C>,
) -> io::Result<()>
where
C: ProxyCoreDecode<Key = FlowKey, Target = Flow<T>>;
pub fn take_out(&mut self) -> Vec<u8>
pub fn on_outbound(&mut self, key: SubKey, data: &[u8])
pub fn on_datagram(&mut self, key: SubKey, from: &Destination, data: &[u8])
pub fn on_outbound_gone<C>(&mut self, key: SubKey, fx: &mut Effects<'_, C>)
where
C: ProxyCoreDecode<Key = FlowKey, Target = Flow<T>>;
pub fn on_transport_eof<C>(&mut self, fx: &mut Effects<'_, C>)
where
C: ProxyCoreDecode<Key = FlowKey, Target = Flow<T>>;
}
字段 内容
sessions 每个存活的线上会话 id 对应一个 Sub:它当前的 SubKey,对 UDP 会话还有其 New 帧中的目标(peer),用作后备对端
generation 最近一次分配的 generation。从 0 开始,每接受一个 New 就用 wrapping_add 加一,因此第一个子流得到 generation 1
straddle held 缓冲区:当前字节事件中由 held 字节补全的帧(其 held 转发引用它们),之后是跨 VMess 数据块被拆开的帧的不完整尾部
partial_at 不完整尾部在 straddle 中的起始位置
out 仅为最近一次调用的帧:下行调用产生的下行帧,或上行调用排队的拒绝帧
flow、sniff 承载连接的 flow 模板,以及是否对子流做嗅探

keys(文档注释称其返回存活的 key,“用于把它们全部关闭”)和 is_empty 在此版本中未被三个协议核心调用;它们通过 on_transport_eof 关闭所有子流。

protocols/src/core/mod.rs
pub enum FlowKey {
Direct,
Sub(SubKey),
}
pub struct SubKey {
pub id: u16,
pub generation: u32,
}

三个承载连接协议核心都声明 type Key = FlowKey。FlowKey::Direct 是普通请求中连接自身的流,FlowKey::Sub 是一个 mux 子流。

generation 的存在源于运行时的 key 规则:对仍然存活的 key 发出 Effect::Open,会以 RuntimeError::DuplicateKey 让连接失败。运行时中的 key 会一直存活到出站消失为止,对于半关闭的流,这意味着直到其读端遇到 EOF。mux 客户端可能在结束某个会话 id 后立即复用它。客户端发送 End 时,解复用器从 sessions 中移除该 id 并推送 Effect::Shutdown,但运行时仍持有旧的出站,直到它排空。随后同一 id 上的 New 会得到新的 generation,因此它的 SubKey 不同,两个出站永远不会冲突。

Demux::session_id(key) 只有在 sessions[key.id] 仍然持有这个完全相同的 key 时,才会把 key 映射回线上 id。因此来自已退役 generation 的下行字节不会被分帧,而是被丢弃。正是这样,客户端 End 之后仍在排空的出站才不会写到线上。

每个解析出的帧都经过 Demux::dispatch。这条路径上从不复制载荷:解复用器推送的 effect 指向一个区间,要么在事件的切片中(Origin::Slice),要么在 held 缓冲区中(Origin::Held)。

flowchart TB
  f["parse_frame"] --> s{"status"}
  s -- New --> n{"已达 MAX_SESSIONS 或 id 仍存活?"}
  n -- 是 --> dec["为该 id 排队 End,丢弃载荷"]
  n -- 否 --> open["分配 SubKey,嗅探载荷,推送 Open"]
  open --> del["投递载荷"]
  s -- "带数据的 Keep" --> k{"id 存活?"}
  k -- 否 --> dec
  k -- 是 --> del
  s -- End --> e["移除 id,推送 Shutdown"]
  s -- "KeepAlive 或不带数据的 Keep" --> x["无操作"]
  del --> u{"UDP 会话?"}
  u -- 否 --> fw["Forward 或 ForwardHeld"]
  u -- 是 --> st["SendTo 或 SendToHeld,发往帧中的对端或会话目标"]

按状态逐一说明规则:

  • New。 必须有 target;parse_meta 对 New 总会读取它,如果缺失,dispatch 仍会以 mux: new session without a target 失败。如果 sessions.len() >= MAX_SESSIONS,或该 id 已存活,解复用器会为该 id 排队一个 End 并丢弃帧的载荷。重复的 New 不会改动该 id 下已存活的会话。否则,它分配 SubKey { id, generation },用 flow.toward(target) 构建子流,推送 Effect::Open { key: FlowKey::Sub(key), target },记录 Sub(UDP 会话的 peer 设为目标),有载荷时再投递载荷。
  • Keep。 存活 id 上带数据的 Keep 会被投递。未知 id(例如服务端已经结束的 id)上带数据的 Keep 会得到一个 End 作为回应,与上游一致,让客户端停止在该 id 上发送。不带数据的 Keep 被忽略。
  • End。 移除该 id,并用 Effect::Shutdown 半关闭出站的写端。未知 id 的 End 被忽略。带数据块的 End 会跳过该数据块。OPTION_ERROR 会被解析,但不改变 End 的处理。
  • KeepAlive。 解复用器中不做任何事。携带它的传输层事件已经重新设置了空闲截止时间。

deliver 根据会话类型和字节的来源选择 effect:

会话 字节位于事件切片中 字节位于 held 缓冲区中
流(peer 为 None) Effect::Forward { key, range } Effect::ForwardHeld { key, range }
UDP(peer 为 New 目标) Effect::SendTo { key, to, range } Effect::SendToHeld { key, to, range }

对于 UDP 会话,Keep 帧带有地址时 to 就是帧自己的地址,否则为会话的 New 目标。这就是 XUDP 的逐包寻址:一个子流、一个出站 key、多个对端。每个 UDP 帧的数据块恰好是一个数据报。流会话中带地址的帧按流字节投递,地址不被使用。

帧区间是绝对的。feed 接收 base,即 plain 在事件切片中的偏移,并把它加到每个区间上。feed_chunks 以事件切片 data 中的区间来接收每个数据块,所以它的区间本来就是绝对的,也没有 base 参数。

开启 sniff 时,目标为 IP 地址的 New(sniff::worth_sniffing)会把载荷交给 sniff::sniff,它先尝试 TLS SNI 嗅探器,再尝试 HTTP Host 嗅探器。命中时,在推送 Effect::Open 之前设置子流的 Flow::sniffed。解复用器只嗅探 New 帧的载荷,不会等待更多字节,也不会启用 SNIFF_TIMEOUT,因为子流在打开的那一刻就被路由,而为一个子流卡住承载连接会让其他所有子流停滞。不带数据的 New,或前几个字节中看不出名称的 New,以未嗅探状态打开。UDP 子流也会做同样的检查,但 TLS 和 HTTP 嗅探器在其上通常什么也找不到。单流连接上的嗅探机制见嗅探。

承载连接协议核心看到的 mux 帧有两种形态,解复用器为每种形态各提供一个入口。

feed feed_chunks
调用方 TrojanCore、VlessCore VMessCore,每个字节事件调用一次,传入所有已打开的数据块
输入 整个未解析的传输层区域,base 为 0 每个已打开数据块在 data 中的区间
末尾不完整的帧 不消费。feed 返回已用的字节数,运行时把剩余尾部交回,拼在下一次读取之前 消费。它被复制进 straddle,由之后的数据块补全,可能属于同一事件,也可能属于下一个事件
完整帧的 effect 基于读缓冲区的 Forward / SendTo 基于读缓冲区的 Forward / SendTo
补全的跨块帧的 effect 不适用 基于 straddle 的 ForwardHeld / SendToHeld

Trojan 或 VLESS 承载连接在这一层是明文,所以不完整的帧可以留在运行时的读缓冲区中。VMess 做不到这一点:ChunkDecoder 就地打开每个 AEAD 数据块并整块消费,未消费的尾部交回时已经是解密后的内容。因此 feed_chunks 在私有的 feed_chunk 中逐个处理该事件的数据块,feed_chunk 把帧的前半部分复制进 straddle,而不是把它留在原处。当有一个不完整帧从 partial_at 开始被 held 时,frame_need 计算出它还需要多少字节(先是元数据长度的 2 字节,然后是元数据,再是数据长度,最后是数据),循环只从该数据块中复制这么多字节。该帧就地补全,位于同一事件中先前补全的帧之后,并以 Origin::Held(start) 分派,其中 start 是它的偏移 partial_at。随后 partial_at 移到 straddle 的末尾,数据块的剩余部分就地解析。在某个帧中间结束的数据块,会把该帧的前半部分追加到 partial_at 处,留给该事件的下一个数据块或下一个事件补全。

held 缓冲区遵循运行时的 pin 规则。held 区间在运行时执行 effect 时才被解析,而在所有已排队的 held effect 执行完之前,运行时不会向协议核心投递任何字节事件。trim_held 在 feed 和 feed_chunks 开始时各运行一次,也就是每个字节事件一次,从不在同一事件的数据块之间运行:它清掉 partial_at 之前已完整的帧,保留不完整的尾部。正因如此,VMessCore 把一次读取打开的所有数据块放在一次 feed_chunks 调用中传入,因为为该读取中较早数据块推送的 held 转发,要等协议核心返回后才会执行。VMessCore::held、VlessCore::held 和 TrojanCore::held 在 Mux 状态下返回 Demux::held,其他状态下返回嗅探前缀。服务端协议核心契约和 pin 规则见服务端协议核心和服务端运行时。

在一个字节事件中,straddle 容纳该事件里由 held 字节补全的每个帧(每个数据块至多一个),之后是不完整的尾部。因此它的大小不超过从上一个事件带过来的尾部加上一次读取的明文。带过来的尾部是某一个帧的一部分,受长度上限约束,而 parse_frame 在 frame_need 信任某个长度之前就会检查这些上限,所以它短于 8708 字节。

子流的出站事件以 FlowKey::Sub(key) 到达承载连接协议核心,协议核心再把它们交给解复用器:

协议核心事件 Demux 调用 out 中的帧
Event::Outbound on_outbound(key, data) data 每 MAX_DATA_LEN 一段,各生成一个不带地址的 Keep
Event::Datagram on_datagram(key, &from, data) 一个以 from 为 UDP 对端地址的 Keep。长于 MAX_DATA_LEN 的数据包被丢弃
Event::OutboundEof、ConnectFailed、OutboundError on_outbound_gone(key, fx) 当该 key 仍是存活的 generation 时,生成一个 End,并对该 key 推送 Effect::Close
Event::TransportEof(承载连接的上行结束) on_transport_eof(fx) 无。对每个存活子流推送 Effect::Close

每个写入 out 的 Demux 调用都会先清空它:下行调用 on_outbound、on_datagram 和 on_outbound_gone,以及上行调用 feed 和 feed_chunks。因此 out 只会容纳最近一次调用的帧;上行调用之后,它只包含该调用的 End 拒绝帧,绝不包含承载连接已经暂存过的下行帧。session_id(key) 不匹配时,下行调用不生成任何帧。回复上的 from 地址是数据报链路报告的数据包来源。会解析域名的链路报告的是它实际收到数据的 IP,所以向域名发送数据的客户端收到的回复带的是 IP。XUDP 客户端用这个地址把每个回复归到对应的对端。三个协议核心都声明 MAX_DATAGRAM = 8192,运行时把出站数据包接收到至多这么大的缓冲区中,所以 on_datagram 从运行时收到的数据永远不会超过 MAX_DATA_LEN。它的长度检查只是兜底。

子流的出站结束后,解复用器发送 End 并在两个方向上关闭该 key。mux 会话没有半关闭:任何一方结束它,整个会话就结束了。

承载连接协议核心以两种方式投递 out:

  • TrojanCore 和 VlessCore 用 fx.stage 原样暂存 demux.out()。上行 feed 之后,如果 demux.take_out() 非空,就暂存它。
  • VMessCore 在每次调用之后用 take_out 取出 out,并用 seal_frames 加密,把它切分成至多 MAX_PAYLOAD(1966)字节的正文数据块。两个方向上,数据块边界与帧边界都相互独立。

只有当 staging 空间还剩 STAGING_RESERVE + n 字节时,运行时才会投递 n 字节的出站读取,而一次出站读取至多 BUF_SIZE 字节。分帧会增加头部,所以每个协议核心都在其预留量中声明最坏情况:

protocols/src/mux/demux.rs
pub const fn downlink_overhead(read_size: usize) -> usize {
read_size
.div_ceil(MAX_DATA_LEN)
.saturating_mul(FRAME_OVERHEAD_MAX)
}
协议核心 BUF_SIZE STAGING_RESERVE MAX_DATAGRAM
TrojanCore 16384 PACKET_HEADER_MAX.next_multiple_of(16) + downlink_overhead(Self::BUF_SIZE) = 272 + 536 = 808 MAX_LENGTH = 8192
VlessCore 16384 272 + downlink_overhead(Self::BUF_SIZE) = 808 8192
VMessCore 32768 4096,一个固定值,用于响应头和一次读取的数据块开销。它不调用 downlink_overhead:流 Keep 的头部为 8 字节,所以一次读取的 mux 分帧开销与数据块开销相比很小 8192

预留量过小会表现为 staging_full(),即错误 staging room below the core's declared reserve,它会结束承载连接。上行事件只保证 STAGING_RESERVE,所以拒绝操作排队的 End 帧(每个 6 字节)必须放得进当时剩余的空间。

下面的时序图展示一个带单个流子流的 VLESS 承载连接。Trojan 的区别只在于标识方式,以及不暂存响应头。VMess 会把每个暂存的字节都加密进数据块。

sequenceDiagram
  participant C as Xray 客户端
  participant R as 运行时
  participant K as VlessCore
  participant D as Demux
  participant O as 出站
  C->>R: 命令为 0x03 的 VLESS 请求头
  R->>K: Event::Transport
  K->>R: 暂存 RESPONSE_HEADER,进入 Relay
  K->>D: Demux::new(flow, sniff)
  C->>R: New id 1,TCP 目标,带数据
  R->>K: Event::Transport
  K->>D: feed(data, 0, fx)
  D-->>R: Open Sub(1, gen 1),Forward 区间
  R->>O: 连接该子流
  O-->>R: 已连接,执行 Forward
  O-->>R: 回复字节
  R->>K: Sub(1, gen 1) 的 Event::Outbound
  K->>D: on_outbound(key, data)
  K->>R: 暂存 out 中的 Keep 帧
  R-->>C: 带数据的 Keep id 1
  O-->>R: 读到 EOF
  R->>K: Event::OutboundEof
  K->>D: on_outbound_gone(key, fx)
  D-->>R: Close Sub(1, gen 1)
  R-->>C: End id 1
  C->>R: 传输层 EOF
  R->>K: Event::TransportEof
  K->>D: on_transport_eof(fx)
  K->>R: ShutdownTransport,Finish

为仍在连接中的子流推送的 Forward 会等待连接完成。运行时按顺序执行 effect,并把所有 effect 挡在一个无法完成的 effect 之后,所以承载连接的上行也会等待,而其他子流的下行照常流动。concepts 测试 stalled_outbound_holds_uplink_but_not_other_downlink 用一个玩具 mux 协议固定了这一行为。

一个 SubKey(会话 id 加 generation)经历以下状态:

stateDiagram-v2
  [*] --> Live: 接受 New,推送 Open
  Live --> Live: 带数据的 Keep,推送 Forward 或 SendTo
  Live --> Live: 出站字节或数据包,暂存 Keep
  Live --> Retiring: 客户端发来 End,推送 Shutdown
  Live --> [*]: 出站 EOF 或出错,暂存 End,推送 Close
  Live --> [*]: 承载连接 EOF,推送 Close
  Retiring --> [*]: 出站读到 EOF 或出错,或承载连接结束

在 Retiring 状态下,该 id 在 sessions 中已经空出,出站的写端已关闭,它再发出的任何数据都会因为 session_id 不再匹配而被丢弃。同一 id 上的 New 会以下一个 generation 开始一个处于 Live 状态的新 SubKey。

不会打开 key 的上行帧不会进入这张图。超出 MAX_SESSIONS 的 New、重复的 New,以及未知 id 上带数据的 Keep,都只会在下行得到一个 End 作为回应,没有其他影响。

XUDP 为 mux.cool 的 UDP 会话增加了两样东西。服务端实现了第一项,对第二项只做解析。

  1. 逐包地址。 UDP 会话的每个 Keep 帧都可以带上自己的对端,每个下行 Keep 都写明数据包来自哪个对端。当网络字节为 NETWORK_UDP 时,parse_meta 读取 Keep 帧上的地址。deliver 发往该地址,帧中没有地址时发往会话的 New 目标。on_datagram 给每个回复标上其来源。在 etemenanki-app 中,UDP 子流的出站是一个 FanOutLink(app/src/outbound/udp_fanout.rs),它按每个数据包自己的目标进行路由,因此一个 XUDP 会话可以到达位于不同出站之后的对端。
  2. 全局 id。 打开 UDP 会话且带数据的 New 帧可以在地址之后附加一个 8 字节的全局 id。Xray 用它让 UDP 会话在新的承载连接上继续。parse_meta 把它解码到 FrameMeta::global_id,使元数据块被完整计算在内,但解复用器并不查看它:每个 New 都会打开一个全新的会话,UDP 会话无法在承载连接丢失后存续。
不变量 机制 由谁固定
帧的长度在解析器等待帧体之前就受到上限约束 parse_frame 和 read_meta / read_data 在每个长度一旦可见时就检查 MAX_META_LEN、MIN_META_LEN 和 MAX_DATA_LEN read_meta_enforces_the_length_caps、frames_parse_from_slices_and_stage_into_buffers
未知的状态字节和网络字节,以及在字段中间结束的元数据,都是错误 SessionStatus::from_byte、dial_network、返回 ProtocolError::Truncated 的 take / take_array rejects_unknown_status_and_network、rejects_truncated_metadata
流 Keep 帧从不被当作地址读取 只有网络字节为 NETWORK_UDP 时,parse_meta 才读取 Keep 的目标 keep_frame_carries_an_address_only_when_flagged_udp、end_and_keepalive_carry_no_target
全局 id 只在带数据的 UDP New 上读取 parse_meta 中 global_id 的匹配 parses_new_udp_session_with_global_id、parses_new_tcp_session
每个承载连接至多 MAX_SESSIONS 个存活会话 id;超额和未知会话被拒绝,但不结束承载连接 dispatch 中的 sessions.len() 和 contains_key 检查,以及 decline unknown_or_excess_sessions_are_declined_with_end
复用的会话 id 永远不会产生重复的运行时 key 每接受一个 New 就分配新的 generation;session_id 匹配完全相同的 SubKey stream_sessions_open_forward_and_end_with_fresh_generations
已退役的 generation 在下行不生成任何帧 对不是当前存活 key 的 key,session_id(key) 返回 None stream_sessions_open_forward_and_end_with_fresh_generations
UDP 载荷发往帧中的对端,没有指定时发往会话目标,回复带有其来源 deliver 以 Sub::peer 作为后备;on_datagram 把 from 传给 encode_keep udp_sessions_address_each_packet_and_tag_replies、encoded_udp_keep_carries_the_peer_address、vless_xudp_datagram_roundtrip
上行载荷按区间转发,从不复制,除非帧跨越了 VMess 数据块 feed 中的 Origin::Slice;只有在 straddle 中补全的帧才使用 Origin::Held stream_sessions_open_forward_and_end_with_fresh_generations、a_frame_straddling_chunks_is_held_and_forwarded_from_the_held_buffer
held 字节在 held 转发执行前保持不动 trim_held 只在下一个字节事件开始时运行,每次 feed 或 feed_chunks 调用一次;运行时的 pin 规则 a_frame_straddling_chunks_is_held_and_forwarded_from_the_held_buffer、held_bytes_are_forwarded_after_the_dial_and_survive_later_rewrites
一次读取由 held 字节补全的每个帧都留在 straddle 中,直到下一个字节事件 VMessCore 把一次读取的所有数据块传给同一次 feed_chunks 调用;feed_chunk 把每个 held 帧补全在之前的帧之后,且不做清理 vmess_keeps_every_frame_one_read_completes、vmess_mux_payload_spans_both_framings
每个下行帧只暂存一次 每个写入 out 的调用(上行和下行)开始时都执行 out.clear() a_downlink_frame_is_not_sent_again_by_the_next_uplink、vless_answers_mux_at_once_and_demultiplexes(下一次上行不暂存任何内容)、xudp_attributes_replies_to_the_right_peer
下行流帧都不超过 MAX_DATA_LEN on_outbound 用 chunks(MAX_DATA_LEN) 切分 vmess_demultiplexes_across_chunk_boundaries(10,000 字节变成两个 Keep 帧)
下行分帧从不超出 staging 空间 Trojan 和 VLESS 的 STAGING_RESERVE 中包含 downlink_overhead;运行时在出站读取前检查预留量 frames_parse_from_slices_and_stage_into_buffers(一个域名 Keep 恰好填满 FRAME_OVERHEAD_MAX)
承载连接在任何子流连接之前就得到应答 VLESS 和 VMess 在进入 Mux 时暂存响应头 vless_answers_mux_at_once_and_demultiplexes、vmess_demultiplexes_across_chunk_boundaries
Trojan 通过地址识别承载连接,忽略端口 is_mux_destination trojan_carries_mux_when_the_connect_names_the_carrier、trojan_mux_tcp_single_stream
VLESS mux 请求不带地址 parse_request_header 在命令字节之后以 mux_destination() 代替 mux_command_synthesises_its_destination、mux_request_header_roundtrips
  • 畸形帧会结束承载连接。 parse_frame 的每个错误(长度超限、未知的状态或网络、字段被截断、codec 拒绝的地址)都会作为 io::Error 从协议核心的 handle 返回。随后运行时结束该连接,所有子流也随之结束。错误消息为 mux: metadata length N exceeds 512、mux: metadata length N below 4、mux: data length N exceeds 8192、mux: unknown session status N、mux: unknown target network N,以及完整元数据块中某个字段被截断时的 truncated input: …。codec 拒绝的地址以 codec 自己的消息失败。
  • 单个会话的问题不会结束承载连接。 超额、重复和未知的会话都以 End 拒绝。某个子流的拨号失败或出站错误会以该 FlowKey::Sub 的 ConnectFailed 或 OutboundError 到达协议核心,on_outbound_gone 发送 End 并只关闭该 key。被拒绝的数据报发送以 Event::SendFailed 到达,只在 debug 级别记录日志,不改变任何状态。
  • 承载连接的上行结束。 收到 Event::TransportEof 或 VMess 上行终止数据块时,协议核心调用 on_transport_eof,对每个存活子流推送 Effect::Close,然后进入 Done,推送 ShutdownTransport 和 Finish。VMess 还会在 ShutdownTransport 之前用 terminate 加密其下行终止块。处于 Retiring 的子流不在 sessions 中;Finish 结束运行时,并随之丢弃它们。
  • 承载连接空闲。 在 RELAY_IDLE_TIMEOUT 时间内两个方向都没有字节事件。Timing::expired 推送 Finish,承载连接结束。
  • staging 空间不足。 fx.stage 发现空间少于承诺值时返回 staging_full(),并结束承载连接。如实声明 STAGING_RESERVE 的协议核心在下行永远不会遇到这种情况。在上行,当拒绝帧超出剩余空间时,可能发生这种情况。
  • 取消。 解复用器不拥有任何任务、计时器或 socket。丢弃运行时就会丢弃协议核心、Demux 以及所有出站。
常量 值 位置 作用
MAX_META_LEN 512 字节 frame.rs 更长的元数据是错误
MIN_META_LEN 4 字节 frame.rs(私有) 更短的元数据是错误
MAX_DATA_LEN 8192 字节 frame.rs 更长的上行数据是错误;下行流字节按它切分;更长的下行数据包被丢弃
FRAME_OVERHEAD_MAX 268 字节 frame.rs 服务端发出的帧的最坏情况头部大小
MAX_SESSIONS 256 demux.rs 每个承载连接的存活会话 id 数;更多的 New 被拒绝
downlink_overhead(16384) 536 字节 demux.rs staging 预留量中为一次 Trojan 或 VLESS 出站读取分帧所占的份额
MUX_PORT 9527 mod.rs 仅供参考
RELAY_IDLE_TIMEOUT 300 秒 protocols/src/core/mod.rs 整个承载连接的空闲上限
MAX_PAYLOAD 1966 字节 protocols/src/vmess/framing.rs 下行帧被加密进的最大 VMess 正文数据块

以下是本页所描述版本中的功能性限制,均属有意设计:

  • 上行的队头阻塞。 只要有一个子流的出站仍在连接或不可写,承载连接上所有子流的上行都会被卡住。有挂起的 held 转发时(一个跨 VMess 数据块的帧),pin 规则还会让下行一直等到它被执行。
  • 没有按子流的空闲超时。 存活的子流会一直持续,直到客户端结束它、它的出站结束,或承载连接结束。
  • 不支持 XUDP 会话恢复。 全局 id 会被解码,但被忽略。
  • 重复的 New 会以针对该存活 id 的 End 拒绝。 服务端上的存活会话保持打开,而客户端被告知该 id 已结束。
  • 仅服务端。 本 crate 没有 mux 客户端,所以 etemenanki-app 和 katana 的出站从不做多路复用。

历史:etemenanki-protocols 2.0.0 会在 VLESS 和 Trojan 承载连接上把下行帧再发送一次,并打乱 VMess mux 的上传数据。2.0.1 修复了这两个问题,katana 3.0.1 把其 lockfile 升级到 2.0.1,因此它的节点也不再出现这两个问题(已知问题)。

测试 文件 固定的行为
parses_new_tcp_session protocols/tests/unit/mux/frame.rs 带端口在前 IPv4 目标的 New 元数据;TCP 上没有全局 id
parses_new_udp_session_with_global_id 同上 UDP 目标之后的 8 字节全局 id
keep_frame_carries_an_address_only_when_flagged_udp 同上 UDP Keep 的对端地址;流 Keep 不解析任何内容
end_and_keepalive_carry_no_target 同上 End 和 KeepAlive 的元数据
rejects_unknown_status_and_network 同上 状态 0x09 和网络 0x07 是错误
rejects_truncated_metadata 同上 过短的元数据和被截断的 IPv4 地址是错误
read_meta_enforces_the_length_caps 同上 拒绝 513 字节和 3 字节的元数据,以及 8193 字节的数据
encoded_keep_round_trips_through_the_reader 同上 流的 encode_keep 能被原样读回
encoded_udp_keep_carries_the_peer_address 同上 带 UDP 对端的 encode_keep
encoded_end_has_no_data_block 同上 encode_end 的布局
frames_parse_from_slices_and_stage_into_buffers 同上 parse_frame 对不完整帧的等待、staging 编码函数,以及 FRAME_OVERHEAD_MAX 恰好容纳 255 字节的域名
stream_sessions_open_forward_and_end_with_fresh_generations protocols/tests/unit/mux/demux.rs Open 与 Forward 的顺序、绝对区间、不完整帧不被消费、End 时的 Shutdown、复用时的新 generation、已退役 key 不生成帧、on_outbound_gone 时的 Close 和 End
unknown_or_excess_sessions_are_declined_with_end 同上 未知 Keep 得到 End;恰好 MAX_SESSIONS 个 Open,下一个 id 得到 End
udp_sessions_address_each_packet_and_tag_replies 同上 逐帧对端、回退到 New 目标、回复标记来源
a_downlink_frame_is_not_sent_again_by_the_next_uplink 同上 像 Trojan 和 VLESS 暂存时那样用 out() 就地读取一个 UDP 回复之后,下一次上行 feed 不在 out 中留下任何内容
a_frame_straddling_chunks_is_held_and_forwarded_from_the_held_buffer 同上 feed_chunks 保存尾部;下一个事件中位于其读取偏移 1000 处的数据块补全它;基于 held 缓冲区的 ForwardHeld,以及后续帧的绝对 Forward;下一个事件时的清理
trojan_carries_mux_when_the_connect_names_the_carrier 同上 对 v1.mux.cool:9527 的 Trojan CONNECT 成为承载连接;分帧的回复;出站 EOF 时的 End 和 Close;传输层 EOF 时的 Finish
vless_answers_mux_at_once_and_demultiplexes 同上 立即暂存 RESPONSE_HEADER;UDP 子流的 SendTo 及其带标记的回复;发往另一个对端的下一个上行数据包不暂存任何内容
vmess_demultiplexes_across_chunk_boundaries 同上 一个帧被拆进两个加密数据块;下行被分帧并加密成客户端能打开的数据块
vmess_keeps_every_frame_one_read_completes 同上 一次读取三个加密数据块,补全两个跨块帧:两个 ForwardHeld 区间都解析到各自的载荷,最后一个帧就地转发
mux_command_synthesises_its_destination protocols/tests/unit/vless/protocol.rs VLESS mux 请求头得到 mux_destination(),且第一个帧保持未读
mux_request_header_roundtrips 同上 VLESS mux 请求头为 19 字节,不带地址
stalled_outbound_holds_uplink_but_not_other_downlink concepts/tests/runtime.rs 卡住的转发会卡住上行,但不影响其他 key 的下行
held_bytes_are_forwarded_after_the_dial_and_survive_later_rewrites 同上 held 转发所依赖的 pin 规则
a_held_range_past_the_buffer_is_rejected 同上 越过 held() 的 held 区间得到 RangeOutOfBounds
vless_mux_tcp_single_stream app/tests/integration/e2e_xray_mux.rs 真实 Xray 客户端经 VLESS 承载连接的单条流
vless_mux_tcp_concurrent_streams_stay_separate 同上 同一承载连接上八条并发的 4 KiB 流各自收到自己的字节
vless_mux_over_ws_tls 同上 运行在 WebSocket 和 TLS 之下的承载连接
vless_xudp_datagram_roundtrip 同上 使用 xudpConcurrency 经 VLESS 承载连接的 UDP
vmess_mux_tcp_single_stream 同上 VMess 数据块流中的 mux 帧
vmess_mux_payload_spans_both_framings 同上 被两种分帧切分的 64 KiB 回显
vmess_xudp_datagram_roundtrip 同上 经 VMess 承载连接的 UDP
xudp_attributes_replies_to_the_right_peer 同上 一个 XUDP 会话上的两个对端,每个回复按其帧地址归属
trojan_mux_tcp_single_stream 同上 经 WebSocket 和 TLS 的、基于地址的 Trojan 标识

在 Etemenanki workspace 中运行它们:

终端窗口
cargo test -p etemenanki-protocols mux
cargo test -p etemenanki-concepts --test runtime
cargo test -p etemenanki-app --test integration mux

集成测试会用 Go 从参考源码树构建 Xray。没有 Go 或构建失败时,它们会打印一行 SKIP: 并通过。xudp 双对端测试有意让两次交换间隔进行:如果背靠背发送,会触发 Xray 自身 mux 客户端中的一个缓冲区别名竞态,使它用较新的地址报告较早的数据包。测试套件的组织方式见测试。