VMess:线格式与协议核心
源码文件:26 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/vmess/mod.rsEtemenanki/protocols/src/vmess/protocol.rsEtemenanki/protocols/src/vmess/framing.rsEtemenanki/protocols/src/vmess/session.rsEtemenanki/protocols/src/vmess/core.rsEtemenanki/protocols/src/vmess/codec.rsEtemenanki/protocols/src/vmess/aead.rsEtemenanki/protocols/src/vmess/keys.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/serve.rsEtemenanki/protocols/tests/unit/vmess/protocol.rsEtemenanki/protocols/tests/unit/vmess/framing.rsEtemenanki/protocols/tests/unit/vmess/codec.rsEtemenanki/protocols/tests/unit/vmess/core.rsEtemenanki/protocols/tests/unit/mux/demux.rsEtemenanki/protocols/tests/pipeline/vmess.rsEtemenanki/app/tests/integration/e2e_xray_vmess.rsEtemenanki/app/tests/integration/e2e_xray_mux.rsEtemenanki/app/tests/support/mod.rskatana/src/outbound/mod.rskatana/src/config.rskatana/src/serve.rs
本页介绍 VMess 在认证 ID 之后写到线上的全部内容:封装后的请求头及其明文布局、响应头、选项位、带 SHAKE128 长度掩码和填充的请求体分块流,以及使用这套格式通信的两端——VMessCore 服务端状态机和 VMessStream、VMessDatagram 客户端 codec。
修改 protocols/src/vmess/ 下除密钥派生和账户表以外的任何内容之前,请先读本页。密钥派生和账户表,连同认证 ID、请求头 AEAD 封套和重放过滤器,见 VMess:密钥与认证。配置方面的内容见 VMess 用户指南。
Etemenanki 中的 VMess 只实现现代的 AEAD 形式:请求头用由用户 UUID 派生的密钥以 AES-128-GCM 封装,请求体是用 AES-128-GCM 或 ChaCha20-Poly1305 封装的分块流。模块按各文件负责的内容划分工作:
| 文件 | 负责 | 交给 |
|---|---|---|
protocols/src/vmess/protocol.rs |
请求头和响应头的字节、Command、Security、RequestOptions、FNV-1a 校验和 |
请求头外层的封套交给 aead.rs |
protocols/src/vmess/framing.rs |
请求体的一个方向:分块的封装与打开、长度掩码、填充、每块的 nonce | 某个方向使用哪个密钥和 IV 交给 session.rs |
protocols/src/vmess/session.rs |
每连接的派生状态:客户端的 OutboundSession,服务端的 chunk_streams 和 response_header |
从请求到响应的派生交给 keys.rs |
protocols/src/vmess/core.rs |
VMessCore,sans-I/O 服务端 |
I/O 交给运行时,子流交给 mux 模块 |
protocols/src/vmess/codec.rs |
VMessStream 和 VMessDatagram,sans-I/O 客户端 |
I/O 交给客户端运行时 |
protocols/src/vmess/mod.rs |
公开接口:所有子模块,以及对 VMessCore、VMessStream、VMessDatagram、Security、RequestOptions、Account、AccountValidator、各密钥 newtype 和 Uuid 的重新导出 |
除了两个为测试和参考保留的 async 辅助函数(decode_response_header 和 ChunkStream::read_chunk),这里没有任何代码做 I/O。服务端协议核心和客户端 codec 都在运行时交给它们的切片上工作,原地解密并报告范围。它们实现的通用约定见 服务端协议核心 和 客户端运行时。
一条 VMess 连接携带一个请求头,随后每个方向各有一条分块流。每条流都以一个空的终止块结束。
sequenceDiagram participant C as 客户端 codec participant S as VMessCore participant O as 出站 C->>S: 认证 ID(16 字节) C->>S: 封装后的请求头 C->>S: 请求体分块 Note over S: TCP:先 Effect::Open,再逐块 Forward S->>O: 拨号目标 O-->>S: Event::Connected S->>C: 封装后的响应头(38 字节) O-->>S: Event::Outbound 字节 S->>C: 请求体分块 C->>S: 终止块 Note over S: 对出站执行 Effect::Shutdown O-->>S: Event::OutboundEof S->>C: 终止块
客户端在发送请求体分块之前不会等待响应头:它的握手在 start 之后立即是 Handshake::Done,响应头作为下行方向的第一个空帧被打开。
客户端发出的第一批数据结构如下:
| 部分 | 大小 | 生成者 |
|---|---|---|
| 认证 ID | 16 | aead::create_auth_id |
| 封装后的长度 | 2 + 16 | aead::seal_vmess_aead_header |
| 连接 nonce | 8 | aead::seal_vmess_aead_header |
| 封装后的请求头 | N + 16 | aead::seal_vmess_aead_header,作用于 encode_request_header 构造的 N 字节明文 |
| 请求体分块 | 可变 | ChunkStream::seal_chunk_into |
前四部分见 VMess:密钥与认证。本页从封装后的请求头内部的 N 字节明文讲起。
encode_request_header 构造明文,parse_request_header 读取明文。所有多字节整数均为大端序。
| 偏移 | 字段 | 大小 | 含义 |
|---|---|---|---|
| 0 | 版本 | 1 | VERSION,恒为 1 |
| 1 | 请求体 IV | 16 | 请求方向的 BodyIv |
| 17 | 请求体密钥 | 16 | 请求方向的 BodyKey |
| 33 | 响应头 | 1 | 一个随机字节,服务端必须把它作为响应头的第一个字节原样回显 |
| 34 | 选项 | 1 | RequestOptions 位,见 选项 |
| 35 | 填充长度与安全类型 | 1 | 高 4 位:填充长度 P(0 到 15)。低 4 位:Security 字节 |
| 36 | 保留 | 1 | 写入 0x00;解析器不读取它 |
| 37 | 命令 | 1 | 0x01 TCP,0x02 UDP,0x03 mux |
| 38 | 地址 | 可变 | 先端口,后地址;mux 请求没有此字段 |
| 38 + A | 填充 | P | 随机字节 |
| 38 + A + P | 校验和 | 4 | 此前所有字节的 FNV-1a 32 |
固定部分为 38 字节,因此最短的合法请求头(不带填充的 mux 请求)为 42 字节。
地址使用 protocols/src/helpers/address.rs 中的 AddressCodec::VMESS,它把端口写在地址之前:
| 字段 | 大小 | 含义 |
|---|---|---|
| 端口 | 2 | 目标端口 |
| 类型 | 1 | 0x01 IPv4,0x02 域名,0x03 IPv6 |
| 地址 | 4、1 + L 或 16 | IPv4 字节;一个长度字节加最多 255 字节的域名;或 IPv6 字节 |
编码后最长的地址为 AddressCodec::MAX_LEN,即 259 字节。
mux 请求完全不携带地址。请求头在命令字节处结束,解析器用 crate::mux::mux_destination() 代替,即 TCP 上的 v1.mux.cool 端口 0,与 Xray 合成的目标相同。Command::network 把 Mux 映射为 DialNetwork::Tcp,因为承载连接本身是一条流;每个子流各自携带自己的网络类型。
pub const VERSION: u8 = 1;
pub enum Command { Tcp, Udp, Mux,}
impl Command { pub fn network(self) -> DialNetwork;}
pub struct RequestSession { pub body_iv: BodyIv, pub body_key: BodyKey, pub response_header: u8, pub padding_len: u8,}
pub struct RequestHeader { pub command: Command, pub destination: Destination, pub security: Security, pub options: RequestOptions, pub session: RequestSession,}
pub fn encode_request_header(cmd_key: &CmdKey, req: &RequestHeader) -> Vec<u8>;pub fn parse_request_header(data: &[u8]) -> io::Result<RequestHeader>;encode_request_header 追加校验和后直接把结果交给 aead::seal_vmess_aead_header,因此它的输出就是完整的封装后请求头,包含认证 ID。parse_request_header 接收 aead::open_vmess_aead_header_slice 返回的明文。
parse_request_header 按以下顺序检查明文,遇到第一个问题就失败。任何失败都会结束连接。错误类型为 io::ErrorKind::InvalidData,唯一的例外是地址超出明文末尾,此时为 io::ErrorKind::UnexpectedEof。
-
下限。 少于 42 字节(38 个固定字节加校验和)时失败,报
vmess: request header too short。 -
校验和。 最后四个字节必须等于它之前所有字节的
fnv1a32,否则解析器报vmess: request header checksum mismatch。校验和在读取任何字段之前检查。 -
版本。 第 0 字节必须是
VERSION;其他值报vmess: unsupported version <n>。 -
选项。
RequestOptions::from_wire拒绝未启用分块掩码的全局填充:vmess: global padding negotiated without chunk masking。 -
安全类型。 第 35 字节的低 4 位必须是
3或4。Security::from_byte对其他值报vmess: unsupported security type <n>,这涵盖了 Xray 的none(5)、zero(6)以及旧版取值。 -
命令。 第 37 字节必须是
0x01、0x02或0x03;Command::from_byte对其他值报vmess: unsupported command <n>。 -
地址。 对 TCP 和 UDP,
AddressCodec::VMESS.read_slice从第 38 字节开始解码端口和地址。它在以下情况失败:未知类型(unknown address type: <n>);空域名、非 UTF-8 域名或其他无效域名(empty domain name、non-utf8 domain、invalid domain name: <name>;域名只能包含 ASCII 字母、数字、-、.和_);字段超出明文末尾。能解析为 IP 字面量的域名会作为 IP 地址返回。mux 请求跳过这一步。 -
精确长度。
38 + address + P + 4必须恰好等于明文长度,计算使用 checked 算术。多出或不足都会报vmess: request header length mismatch。正是这一检查阻止了尾随字节藏进经过认证的请求头。
服务端用四个明文字节作答,分成两段 AEAD,用由响应方向请求体密钥和 IV 派生的密钥封装:
| 部分 | 大小 | 内容 |
|---|---|---|
| 封装后的长度 | 2 + 16 | 值 4,用 GcmKey::response_len 和 GcmNonce::response_len 封装 |
| 封装后的载荷 | 4 + 16 | [response_header, 0x00, 0x00, 0x00],用 GcmKey::response_payload 和 GcmNonce::response_payload 封装 |
服务端总是发送选项 0,且不带动态端口命令,因此封装后的响应头恒为 38 字节。
pub struct ResponseSession { pub body_key: BodyKey, pub body_iv: BodyIv, pub response_header: u8,}
impl RequestSession { pub fn response(self) -> ResponseSession;}
pub fn encode_response_header(resp: &ResponseSession) -> Vec<u8>;
pub async fn decode_response_header<R: AsyncRead + Unpin>( reader: &mut R, resp: &ResponseSession,) -> io::Result<()>;
pub fn decode_response_header_slice( buf: &[u8], resp: &ResponseSession,) -> io::Result<Option<usize>>;客户端 codec 使用 decode_response_header_slice。缓冲区不足以容纳任一段时,它返回 Ok(None);成功时返回 Ok(Some(n)),其中 n 是响应头占用的字节数;tag 校验失败,或载荷第一个字节不是客户端选定的 response_header 字节时返回错误:vmess: unexpected response header。
安全类型与选项
Section titled “安全类型与选项”请求头第 35 字节的低 4 位为两个方向选定请求体 AEAD:
| 变体 | 字节 | 请求体 AEAD | 分块密钥 |
|---|---|---|---|
Security::Aes128Gcm |
3 |
AES-128-GCM | 16 字节请求体密钥 |
Security::ChaCha20Poly1305 |
4 |
ChaCha20-Poly1305 | md5(k) 后接 md5(md5(k)),共 32 字节(gen_chacha_key) |
pub enum Security { Aes128Gcm, ChaCha20Poly1305,}
impl Security { pub fn byte(self) -> u8; pub fn from_byte(b: u8) -> io::Result<Self>;}两种密码的 tag 都是 16 字节(aead::TAG_SIZE),nonce 都是 12 字节,因此帧处理代码除 BodyCipher 外不按密码分支。服务端接受客户端指定的任一种;它自身没有密码设置。在客户端一侧,etemenanki-app 把缺省的 security、auto 和 aes-128-gcm 映射为 Aes128Gcm,把 chacha20-poly1305 映射为 ChaCha20Poly1305,不区分大小写;其他值会使出站构建失败,报 unknown vmess security "<value>"(app/src/outbound/mod.rs → parse_security)。
RequestOptions 包装选项字节。有含义的位共三个;from_wire 会保留收到的其他位,但没有任何代码读取它们:
| 常量 | 位 | 访问器 | 在 ChunkStream 中的作用 |
|---|---|---|---|
OPT_CHUNK_STREAM |
0x01 |
chunk_stream() |
new 和 modern 总是设置它;from_wire 不要求它。请求体总是分块的;帧处理代码从不检查这一位。 |
OPT_CHUNK_MASKING |
0x04 |
chunk_masking() |
每个长度字段与两个 SHAKE128 字节做异或 |
OPT_GLOBAL_PADDING |
0x08 |
global_padding() |
在每个分块后追加 0 到 63 个随机字节,个数取自 SHAKE128 |
pub struct RequestOptions(u8);
impl RequestOptions { pub fn new(chunk_masking: bool, global_padding: bool) -> io::Result<Self>; pub fn modern(global_padding: bool) -> Self; pub fn from_wire(bits: u8) -> io::Result<Self>; pub fn chunk_stream(self) -> bool; pub fn chunk_masking(self) -> bool; pub fn global_padding(self) -> bool;}填充要求掩码。 全局填充的长度取自掩码所用的同一条 SHAKE128 密钥流。Xray 从掩码长度解析器中取得填充长度,并拒绝只要求全局填充而不启用掩码的请求,因此这种组合没有可互操作的含义。该类型在两个入口都排除了它:
RequestOptions::new(false, true)失败,错误类型为io::ErrorKind::InvalidInput,报vmess: global padding requires chunk masking。RequestOptions::from_wire对同样的位失败,错误类型为io::ErrorKind::InvalidData,报vmess: global padding negotiated without chunk masking。RequestOptions::modern(global_padding)总是同时设置分块流和掩码,因此不会产生这种非法组合。客户端 codec 通过OutboundSession::new使用它。
请求体分块帧
Section titled “请求体分块帧”protocols/src/vmess/framing.rs 在明文和分块之间相互转换,每个方向一个 ChunkStream。
| 字段 | 大小 | 含义 |
|---|---|---|
| 长度 | 2 | mask XOR (n + 16 + p),大端序;不启用分块掩码时 mask 为 0 |
| 密文 | n | 原地封装后的明文 |
| Tag | 16 | 分离的 AEAD tag,写在密文之后 |
| 填充 | p | 随机字节;不启用全局填充时 p 为 0 |
长度字段统计其后的全部内容:密文、tag 和填充。长度等于 16 + p 的分块不携带明文,即终止块;ChunkHeader::is_terminator 识别它,两种读取方式都会据此报告流结束。
逐步处理一个分块
Section titled “逐步处理一个分块”一个方向的编码器和解码器由相同的密钥、IV 和选项构建,每处理一个分块,两者都按相同顺序各推进一次状态。这个顺序就是协议的全部:
flowchart TB
A["next_mask()"] --> B{"global_padding?"}
B -- 是 --> C["p = shake.next_padding_len()"]
B -- 否 --> D["p = 0"]
C --> E{"chunk_masking?"}
D --> E
E -- 是 --> F["mask = shake.next_u16()"]
E -- 否 --> G["mask = 0"]
F --> H["next_nonce():count 写入字节 0..2,count += 1"]
G --> H
H --> J["size = mask XOR (n + 16 + p)"]
J --> I["原地封装明文,追加 tag"]
I --> K["填充 p 个随机字节"]
- SHAKE128。
ChunkStream::new用该方向完整的 16 字节请求体 IV 为aead::Shake128设定种子。next_padding_len是next_u16() % 64,因此填充为 0 到 63 字节;next_u16以大端序读取两个密钥流字节。两个选项都启用时,填充长度在掩码之前取出,与 Xray 的AuthenticationWriter一致。 - Nonce。 12 字节 nonce 的初值是请求体 IV 的前 12 个字节。
next_nonce用分块计数器(大端序)覆盖第 0 和第 1 字节,然后用wrapping_add递增计数器。计数器是u16,因此 nonce 为count || body_iv[2..12]。 - AEAD。 每个分块都以空的关联数据原地封装,tag 分离(
BodyCipher::seal_in_place、BodyCipher::open_in_place)。
pub const MAX_PAYLOAD: usize = 2048 - TAG_SIZE - 2 - 64;pub const MAX_PADDING: usize = 64;pub const CHUNK_OVERHEAD_MAX: usize = 2 + TAG_SIZE + MAX_PADDING;
pub struct ChunkHeader { pub size: usize, pub padding: usize,}
impl ChunkHeader { pub fn is_terminator(&self) -> bool; pub fn plain_len(&self) -> usize;}
pub struct ChunkConfig<'a> { pub security: Security, pub body_key: &'a BodyKey, pub body_iv: &'a BodyIv, pub options: RequestOptions,}
pub struct ChunkStream { /* cipher, shake, nonce, count: u16, options */ }
impl ChunkStream { pub fn new(config: ChunkConfig<'_>) -> Self; pub fn seal_chunk(&mut self, plaintext: &[u8]) -> BytesMut; pub fn seal_chunk_into(&mut self, plaintext: &[u8], out: &mut Staging<'_>) -> Option<()>; pub fn seal_terminator(&mut self) -> BytesMut; pub fn seal_terminator_into(&mut self, out: &mut Staging<'_>) -> Option<()>; pub fn decode_header(&mut self, size_buf: [u8; 2]) -> io::Result<ChunkHeader>; pub fn open_body_in_place( &mut self, header: &ChunkHeader, body: &mut [u8], ) -> io::Result<usize>; pub async fn read_chunk<R: AsyncRead + Unpin>( &mut self, reader: &mut R, ) -> io::Result<Option<Vec<u8>>>;}协议核心和 codec 使用的是 seal_chunk_into 这条路径。它在触碰密钥流或计数器之前,先用 plaintext.len() + CHUNK_OVERHEAD_MAX 检查 out.room(),空间不足时返回 None,流的状态保持不变。因此调用方可以在腾出更多空间后重试,而不会让该方向失去同步。seal_chunk 和 seal_terminator 会分配一个 BytesMut;在 framing.rs 之外只有测试调用它们。
decode_header 去掉长度的掩码,推进密钥流,并拒绝小于 16 + p 的长度,报 vmess: chunk size below overhead。open_body_in_place 要求 body.len() == header.size(vmess: chunk body length mismatch),推进 nonce,并用 tag 打开密文(vmess: body chunk open failed)。
ChunkDecoder
Section titled “ChunkDecoder”运行时按传输层产生的任意片段交付线上字节,一个分块可能跨越两次读取。对它的长度字段解码两次会使 SHAKE 密钥流推进两次,让该方向永久失去同步。ChunkDecoder 在两次调用之间保存已解码的块头:
pub enum ChunkStep { NeedMore, Data { consumed: usize, plain: Range<usize>, }, End { consumed: usize },}
pub struct ChunkDecoder { stream: ChunkStream, pending: Option<ChunkHeader>,}
impl ChunkDecoder { pub fn new(stream: ChunkStream) -> Self; pub fn open(&mut self, wire: &mut [u8]) -> io::Result<ChunkStep>;}stateDiagram-v2 [*] --> NoHeader NoHeader --> NoHeader: 不足 2 字节 / NeedMore NoHeader --> Pending: 对字节 0..2 的副本执行 decode_header Pending --> Pending: 块体不完整 / NeedMore Pending --> NoHeader: 整个分块已到齐 / Data 或 End
open 从前两个字节的副本解码长度字段,并存入 pending。在可用字节少于 2 + size 时,它返回 NeedMore,调用方随后在末尾追加更多字节后再次提交同样的字节。分块完整后,它原地打开块体,返回 Data(附带切片内的明文范围),对终止块则返回 End,并清空 pending。两种 consumed 值都涵盖长度字段、块体、tag 和填充。
每个方向都有自己的密钥和 IV。客户端随机选取请求方向的值;双方都由它们派生出响应方向的值。
| 方向 | 请求体密钥 | 请求体 IV | 构建者 |
|---|---|---|---|
| 请求(客户端到服务端) | BodyKey::random() |
BodyIv::random() |
客户端:OutboundSession::request_encoder。服务端:chunk_streams 的第一条流 |
| 响应(服务端到客户端) | BodyKey::response(),SHA-256(key)[..16] |
BodyIv::response(),SHA-256(iv)[..16] |
服务端:chunk_streams 的第二条流。客户端:OutboundSession::response_decoder |
两个方向都使用请求头中的安全类型和选项。调用方通过 BodyKey 和 BodyIv newtype 上的 response() 方法派生响应值,而不是直接对原始字节做哈希,因此不会意外地用 IV 构造出响应密钥;详见 VMess:密钥与认证。
pub struct OutboundSession { cmd_key: CmdKey, request: RequestHeader, response: ResponseSession,}
impl OutboundSession { pub fn new( uuid: Uuid, security: Security, global_padding: bool, command: Command, destination: Destination, ) -> Self; pub fn sealed_request_header(&self) -> Vec<u8>; pub fn request_encoder(&self) -> ChunkStream; pub fn response_decoder(&self) -> ChunkStream; pub fn response_session(&self) -> ResponseSession;}
pub fn chunk_streams(request: &RequestHeader) -> (ChunkStream, ChunkStream);pub fn response_header(request: &RequestHeader) -> Vec<u8>;OutboundSession::new 取两个随机字节:第一个作为 response_header 字节,第二个的低 4 位作为请求头填充长度(0 到 15)。它用 RequestOptions::modern(global_padding) 构建选项,因此通过这里构建的客户端总是启用分块掩码。
服务端协议核心:VMessCore
Section titled “服务端协议核心:VMessCore”pub struct VMessCore<T> { validator: Arc<AccountValidator<T>>, now: fn() -> i64, sniff: bool, source: Option<IpAddr>, timing: Timing, state: State<T>, flow: Option<Flow<T>>, decoder: Option<ChunkDecoder>, encoder: Option<ChunkStream>, response: Vec<u8>, prefix: SniffPrefix, uplink_done: bool,}
impl<T> VMessCore<T> { pub const BUF_SIZE: usize = 32 * 1024;
pub fn new( validator: Arc<AccountValidator<T>>, now: fn() -> i64, sniff: bool, source: Option<IpAddr>, ) -> Self;
pub fn is_established(&self) -> bool;}
impl<T: Send + Sync + 'static> ProxyCoreDecode for VMessCore<T> { type Key = FlowKey; type Target = Flow<T>; type Error = io::Error; type TransportAddr = ();
const STAGING_RESERVE: usize = 4096; const MAX_DATAGRAM: usize = 8192;
fn handle( &mut self, event: Event<'_, Self>, fx: &mut Effects<'_, Self>, ) -> Result<usize, io::Error>;
fn held(&self) -> &[u8];}- 注入的时钟。
now是用来校验认证 ID 时间戳的时钟:AuthId状态调用self.validator.authenticate(&authid, (self.now)())。它是普通的fn() -> i64而不是闭包,因此协议核心不必为它携带任何捕获状态,测试也可以传入一个返回固定时间的函数。etemenanki-app(app/src/serve.rs)和 katana 都传入aead::now_unix。这个时钟只用于这项校验;客户端一侧直接在aead::seal_vmess_aead_header内部用now_unix给认证 ID 打时间戳。 T是账户表携带的每用户数据。它以Flow中的NetworkUser { authorization: UserAuthorization::Uuid(uuid), user_data }到达出站,katana 正是借此把流量归属到面板用户。source是客户端的 IP,会被复制到协议核心打开的每个Flow中,包括 mux 承载连接的子流。sniff为目标是 IP 地址的 TCP 请求开启目标嗅探,并会传递给 mux 解复用器。
stateDiagram-v2 [*] --> AuthId AuthId --> Header: validator.authenticate 匹配成功 Header --> Tcp: TCP,不嗅探 Header --> Sniff: 目标为 IP 的 TCP,开启嗅探 Header --> Udp: UDP Header --> Mux: mux Sniff --> Tcp: 得出结论、超时、终止块或 EOF Udp --> Done: 终止块、EOF 或出站失效 Mux --> Done: 终止块或 EOF AuthId --> Done: TransportEof Header --> Done: TransportEof Tcp --> [*]: Passthrough 结束 Done --> [*]
State<T> 保存各状态的数据:Header 保存 AuthId、匹配到的 CmdKey 和 NetworkUser;Tcp 保存一个 Passthrough<FlowKey> 和一个 replied 标志;Udp 保存一个 opened 标志;Mux 持有一个 Demux<T>。
AuthId
Section titled “AuthId”协议核心等到 16 个字节到齐,然后询问验证器。未命中——无论是未知用户、时间戳超出窗口还是重放的 ID——都会失败,错误类型为 io::ErrorKind::PermissionDenied,报 vmess: unknown user or invalid auth id。命中则进入 Header,并恰好消费 16 个字节。
Header
Section titled “Header”在整个封套到齐之前,aead::open_vmess_aead_header_slice 返回 Ok(None),协议核心在此期间不消费任何字节。封套打开后,协议核心:
- 用
parse_request_header解析明文; - 用
chunk_streams构建两条分块流,并把请求方向的流包装进ChunkDecoder; - 用
response_header封装响应头,并在该发送之前一直保存在response中; - 用目标、用户和
source构建Flow; - 按命令分支(见下表),并返回封套长度。
随后运行时会对剩余字节再次调用,因此同一次读取中到达的请求体分块会在下一个状态中处理。
| 命令 | 下一状态 | 何时写入响应头 | 何时打开出站 | 进入的阶段 |
|---|---|---|---|---|
| TCP | Tcp |
收到 Event::Connected 时;如果先封装了下行字节或下行终止块,则更早 |
立即:Effect::Open { key: FlowKey::Direct } |
Phase::Relay |
| 目标为 IP 的 TCP,开启嗅探 | Sniff |
同 TCP,在流打开后 | 嗅探结束时 | Phase::Sniff |
| UDP | Udp |
立即 | 随第一个数据包 | Phase::Relay |
| Mux | Mux |
立即 | 由解复用器按子流打开 | Phase::Relay |
响应头由 reply 写入 staging:它只写一次 response,然后将其清空。seal 和 terminate 都先调用 reply,因此响应头绝不会出现在请求体分块之后。对 TCP 而言,等待 Connected 意味着目标不可达时,客户端看到的是连接在没有响应头的情况下关闭。UDP 关联和 mux 承载连接没有单一的拨号可等。
从传输层打开的每个分块都会变成一个作用于其原地明文范围的 Effect::Forward;空范围会被跳过。下行字节(Event::Outbound)被切成 MAX_PAYLOAD 大小的片段并封装为分块。半关闭通过 Passthrough 处理:
| 触发条件 | 协议核心的动作 |
|---|---|
| 上行终止块 | 设置 uplink_done,然后调用 Passthrough::on_transport_eof:对出站执行 Effect::Shutdown,若出站已经结束则再加 Effect::Finish |
终止块之前的 Event::TransportEof |
Passthrough::on_transport_eof,同样的半关闭 |
终止块之后的 Event::TransportEof |
若出站一侧也已关闭则 Effect::Finish;否则什么也不做 |
Event::OutboundEof |
terminate(若响应头尚未发出则先发响应头,再发下行终止块;编码器随之丢弃),然后 Passthrough::on_outbound_eof:Effect::ShutdownTransport,若传输层已经结束则再加 Effect::Finish |
Event::ConnectFailed 或 Event::OutboundError |
Passthrough::on_outbound_gone:Effect::ShutdownTransport 和 Effect::Finish |
对于目标是 IP(sniff::worth_sniffing)且开启了嗅探的 TCP 请求,协议核心先把明文收集到 SniffPrefix 中。每个打开的分块都会推入前缀,直到嗅探器返回 Verdict::More 以外的结论。然后 open_sniffed 把结果存入 Flow::sniffed,发出 Effect::Open 和针对已收集字节的 Effect::ForwardHeld,并在同一轮中直接转发该分块的剩余部分以及之后的每个分块。前缀在下一个字节事件时清空,此时运行时已经转发了暂存的字节。
当 SNIFF_TIMEOUT 截止时间到达、上行终止块到达或出现 TransportEof 时,嗅探也会结束,流以已收集的内容打开。各嗅探器及其预算见 嗅探。
每个上行分块是发往请求头中目标的一个数据包。第一个数据包发出 Effect::Open { key: FlowKey::Direct };每个数据包都发出带该分块明文范围的 Effect::SendTo。每个下行数据报(Event::Datagram)恰好变成一个分块(seal 且 whole = true),因为分块边界就是数据包边界。关联在上行终止块或 TransportEof 时通过 finish_udp 结束:若出站已打开则 Effect::Close,然后是下行终止块、Effect::ShutdownTransport 和 Effect::Finish。出站失败或中断会立即结束关联。发送失败(Event::SendFailed)以 debug 级别记录后丢弃,与 UDP 本身的行为一致。
mux 请求把分块流变成 mux.cool 承载连接。分块边界与 mux 帧边界相互独立:一个分块可以容纳多个帧,一个帧也可以跨越多个分块。一个字节事件可能打开多个分块,协议核心把它们的全部明文范围一次性交给解复用器:
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>>;VMessCore 以 demux.feed_chunks(data, &opened.plain, fx) 的形式调用它,每个字节事件调用一次。feed_chunks 整块消费每个分块:完整的帧按其在 data 中的范围分派;末尾不完整的帧被复制进解复用器的暂存缓冲区,由同一事件或下一个事件中的后续分块补全,再从暂存缓冲区用 Effect::ForwardHeld 转发(UDP 子流用 Effect::SendToHeld)。同一事件中补全的多个帧在暂存缓冲区中各占自己的位置,因为运行时要到事件结束后才执行这些暂存转发。下一个字节事件开始时,解复用器从暂存缓冲区中丢弃已补全的帧,只保留不完整的尾部。这就是为什么 VMessCore::held 在 Mux 状态下返回 Demux::held,在其他状态下返回嗅探前缀。解复用器排队发往客户端的帧——无论来自子流数据还是它自己的 End 回复——通过 take_out 收集,并封装为每块最多 MAX_PAYLOAD 的普通分块。子流事件带着 FlowKey::Sub(SubKey) 到达,交给 on_outbound、on_datagram 或 on_outbound_gone 处理。上行终止块或 TransportEof 会关闭所有子流(Demux::on_transport_eof),发送下行终止块并结束。帧格式、会话 generation 和 XUDP 见 Mux 与 XUDP。
每个携带字节的事件开始时都会触碰 Timing,每次阶段变化时由 enter 调整:
| 截止时间 | 常量 | 到期时 |
|---|---|---|
| 握手 | HANDSHAKE_TIMEOUT,10 秒,由第一个传输层事件启动 |
handle 返回 handshake_timed_out(),io::ErrorKind::TimedOut |
| 嗅探 | SNIFF_TIMEOUT,300 毫秒 |
open_sniffed:流以已收集的内容打开 |
| 中继空闲 | RELAY_IDLE_TIMEOUT,300 秒,每个字节事件都会刷新 |
Timing::expired 推入 Effect::Finish |
从 Phase::Relay 起,is_established 为真。etemenanki-app 和 katana 的服务循环轮询它来结束各自的握手看门狗,因此仍在嗅探的连接会被视为仍处于握手阶段。
| 常量 | 值 | 原因 |
|---|---|---|
VMessCore::BUF_SIZE |
32 KiB | 运行时的读缓冲区和 staging 缓冲区。app 和 katana 用它实例化运行时。 |
STAGING_RESERVE |
4096 | 覆盖 38 字节的响应头、下行终止块以及一次出站读取的分块开销:一次最多 BUF_SIZE - STAGING_RESERVE 字节的读取最多切成 15 个分块,每块开销 82 字节。 |
MAX_DATAGRAM |
8192 | 运行时从数据报出站整块读取的最大 UDP 数据包;更长的数据包会被截断,与内核 recv 的行为相同。只有当 staging 中空闲空间达到 STAGING_RESERVE + MAX_DATAGRAM 字节时,运行时才会轮询数据报出站,因此一个数据包总能作为一个分块放下,其长度字段也远在 u16 范围之内。 |
MAX_PAYLOAD |
1966 | 流分块的明文上限:2048 - 16 - 2 - 64,因此即使填充最大,一个成帧的分块也不超过 2 KiB。 |
CHUNK_OVERHEAD_MAX |
82 | 2 + 16 + 64:一个封装后的分块在明文之外最多增加的字节数。 |
seal 和 terminate 把 seal_chunk_into 返回的 None 转成 staging_full()(staging room below the core's declared reserve,io::ErrorKind::Other),这是针对提供的空间少于其承诺的运行时的错误。遵守约定的运行时永远不会触发它。
客户端 codec
Section titled “客户端 codec”protocols/src/vmess/codec.rs 把客户端实现为两个 codec,它们共享一个 Session:封装后的请求头、请求方向的 ChunkStream、作用于响应流的 ChunkDecoder、ResponseSession 和一个 replied 标志。
const HEADER_MAX: usize = 2 + 16 + 8 + 38 + 259 + 15 + 4 + 16;
pub struct VMessStream { /* session */ }
impl VMessStream { pub fn new(uuid: Uuid, security: Security, global_padding: bool, dest: &Destination) -> Self;}
impl ProxyCoreEncodeHandshake for VMessStream { type Target = Destination; type Error = io::Error; const STAGING_RESERVE: usize = HEADER_MAX.next_multiple_of(64); // start, reply, finish}
impl ProxyCoreEncode for VMessStream { fn seal(&mut self, plain: &[u8], out: &mut Staging<'_>) -> io::Result<usize>; fn open(&mut self, wire: &mut [u8]) -> io::Result<Opened>;}
pub struct VMessDatagram { /* session, target */ }
impl VMessDatagram { pub fn new(uuid: Uuid, security: Security, global_padding: bool, target: &Destination) -> Self;}
impl ProxyCoreEncodeDatagram for VMessDatagram { fn seal_to( &mut self, plain: &[u8], _: &Destination, out: &mut Staging<'_>, ) -> io::Result<Option<()>>; fn open_from(&mut self, wire: &mut [u8]) -> io::Result<OpenedFrom>;}start把完整的封装后请求头(包含认证 ID)写入 staging,并返回Handshake::Done,因此明文可以立即开始传输。seal每次调用最多取MAX_PAYLOAD字节,封装成一个分块。运行时会再次调用它处理剩余部分。open先用decode_response_header_slice打开响应头,并将其报告为plain范围为空的Opened::Frame;此后把ChunkStep一一对应地映射为Opened。finish把终止块写入 staging。reply永远不会被调用,因为start返回Done;若被调用,它会报vmess: the response header opens with the first frame。
- 请求头的命令为
Command::Udp,目标为该流的目标。 seal_to把一个数据包封装成一个分块;当空间小于数据包加CHUNK_OVERHEAD_MAX时,它不写入任何内容并返回Ok(None)。to参数被忽略:一个 VMess UDP 关联只有一个目标,固定在请求头中。open_from把每个非空帧都归属到该目标;对响应头和终止块,它返回None作为来源。start、reply和finish的行为与VMessStream相同。
开启全局填充。 两个 codec 都把 global_padding 作为构造参数。etemenanki-app 总是传入 true(app/src/outbound/mod.rs 中的 "vmess" 分支),与 Xray 客户端发送的内容一致;与 Xray 服务端的互操作由 app_client_vmess_ws_xray_server_early_data_plain 覆盖。面板节点 agent katana 则传入其出站的 global_padding 设置,默认为 false。
Staging 预留。 HEADER_MAX 为 358,STAGING_RESERVE 将其向上取整为 384。这个和不包含 16 字节的认证 ID;真正的最坏情况(255 字节域名加 15 字节请求头填充)为 374 字节,仍在取整后的预留之内。如果修改请求头或取整方式,请连同认证 ID 重新计算最坏情况。一个分块在明文之外最多需要 CHUNK_OVERHEAD_MAX(82)字节,远低于预留值。
codec 发现空间少于其声明的预留时会失败,报 vmess: staging room below the declared reserve(io::ErrorKind::Other);VMessDatagram::seal_to 则返回 Ok(None),不写入任何内容。
| 不变量 | 由谁保证 | 由哪些测试固定 |
|---|---|---|
| 只接受版本为 1、安全类型已知、命令已知、选项组合合法且 FNV-1a 校验和匹配的请求头 | parse_request_header、Security::from_byte、Command::from_byte、RequestOptions::from_wire |
protocols/tests/unit/vmess/protocol.rs 中的 rejects_unknown_version、rejects_unknown_security、rejects_unknown_command、rejects_invalid_option_relationship |
| 请求头明文在地址、填充和校验和之外没有多余字节 | parse_request_header 中的精确长度检查 |
request_header_roundtrips(合法形态) |
| 永远不会在未启用分块掩码时协商全局填充 | RequestOptions::new、RequestOptions::from_wire、RequestOptions::modern |
rejects_invalid_option_relationship |
| 一个方向的两端每个分块各推进一次 SHAKE128 和计数器,先填充后掩码 | ChunkStream::next_mask、ChunkStream::next_nonce |
protocols/tests/unit/vmess/framing.rs 中的 body_chunks_roundtrip_gcm、body_chunks_roundtrip_chacha、body_chunks_roundtrip_masking_without_padding、body_chunks_roundtrip_plain_length(两端一致);Xray 互操作测试(顺序与 Xray 一致) |
| 跨读取拆分的分块只解码一次长度字段 | ChunkDecoder::pending |
protocols/tests/unit/vmess/core.rs 中的 a_chunk_split_across_reads_decodes_its_header_once |
| 被拒绝的封装不改变流的状态 | seal_chunk_into 开头的空间检查 |
只覆盖拒绝本身:chunks_seal_into_staging_and_open_in_place(framing),protocols/tests/unit/vmess/codec.rs 中的 datagram_codec_refuses_a_packet_that_does_not_fit(不写入任何内容) |
| TCP 响应头只在拨号成功后发出,且总在第一个下行分块之前 | State::Tcp { replied },seal 和 terminate 开头的 reply |
tcp_request_opens_and_replies_once_connected |
| UDP 和 mux 立即回复请求头 | Header 状态中的 reply |
udp_replies_at_once_and_maps_chunks_to_packets;protocols/tests/unit/mux/demux.rs 中的 vmess_demultiplexes_across_chunk_boundaries |
| 两个方向上都是一个 UDP 数据包对应一个分块 | 协议核心中的 seal(.., whole = true),codec 中的 seal_to |
udp_replies_at_once_and_maps_chunks_to_packets、new_server_vs_new_client_udp |
| 上行终止块只半关闭出站,而不是关闭整个连接 | Passthrough::on_transport_eof |
the_uplink_terminator_half_closes_the_outbound |
| 下行终止块只发送一次 | terminate 把编码器从 Option 中取出 |
tcp_request_opens_and_replies_once_connected(终止块在最后一个分块之后) |
| 重放的认证 ID 会被拒绝 | AccountValidator::authenticate |
a_replayed_auth_id_is_refused |
| mux 帧可以跨越分块边界,一次读取补全的每个帧都从暂存缓冲区转发 | Demux::feed_chunks、VMessCore::held |
vmess_demultiplexes_across_chunk_boundaries、vmess_keeps_every_frame_one_read_completes、vmess_mux_payload_spans_both_framings |
协议核心返回的任何错误都会结束连接。解析和帧处理代码用 get、take、take_array、checked_add 或 saturating_add 读取由对端控制的偏移,而不是直接索引,因此畸形输入只会变成错误,不会导致 panic。
| 位置 | 错误 | 类型 |
|---|---|---|
AuthId 状态 |
vmess: unknown user or invalid auth id |
PermissionDenied |
| 请求头封套 | vmess: AEAD header open failed(gcm_open 中 tag 校验失败) |
InvalidData |
parse_request_header |
vmess: request header too short、checksum mismatch、unsupported version、global padding negotiated without chunk masking、unsupported security type、unsupported command、request header length mismatch |
InvalidData |
parse_request_header,地址 |
unknown address type、empty domain name、non-utf8 domain、invalid domain name;字段超出明文末尾 |
InvalidData;最后一种为 UnexpectedEof |
ChunkStream::decode_header |
vmess: chunk size below overhead |
InvalidData |
ChunkStream::open_body_in_place |
vmess: chunk body length mismatch、vmess: body chunk open failed、vmess: invalid tag |
InvalidData |
下行结束后调用 seal |
vmess: downlink already ended |
InvalidData |
| 握手截止时间 | client did not complete its request in time |
TimedOut |
| 协议核心 staging 空间不足 | staging room below the core's declared reserve |
Other |
| 客户端响应头 | vmess: unexpected response header 或 vmess: AEAD header open failed |
InvalidData |
| 客户端 codec staging 空间不足 | vmess: staging room below the declared reserve |
Other |
出站失败是事件而不是错误:ConnectFailed 和 OutboundError 会结束 TCP 或 UDP 连接,对 mux 承载连接则只关闭受影响的子流。在 AuthId 或 Header 状态期间出现传输层 EOF 时,连接会静默结束。
运行 cargo test -p etemenanki-protocols vmess。
| 文件 | 测试 |
|---|---|
protocols/tests/unit/vmess/protocol.rs |
request_header_roundtrips、rejects_invalid_option_relationship、rejects_unknown_command、rejects_unknown_security、rejects_unknown_version、response_header_roundtrips、request_and_response_headers_open_from_slices(不完整的缓冲区返回 None,错误的响应字节会失败) |
protocols/tests/unit/vmess/framing.rs |
body_chunks_roundtrip_gcm、body_chunks_roundtrip_chacha、body_chunks_roundtrip_masking_without_padding、body_chunks_roundtrip_plain_length、chunks_seal_into_staging_and_open_in_place |
protocols/tests/unit/vmess/codec.rs |
stream_codec_seals_the_header_and_chunks_and_opens_the_response(写入 MAX_PAYLOAD + 1 字节时只取 MAX_PAYLOAD)、datagram_codec_refuses_a_packet_that_does_not_fit |
protocols/tests/unit/vmess/core.rs |
tcp_request_opens_and_replies_once_connected、a_replayed_auth_id_is_refused、the_uplink_terminator_half_closes_the_outbound、a_chunk_split_across_reads_decodes_its_header_once、sniffing_reads_chunks_until_a_host_appears、udp_replies_at_once_and_maps_chunks_to_packets |
protocols/tests/unit/mux/demux.rs |
vmess_demultiplexes_across_chunk_boundaries、vmess_keeps_every_frame_one_read_completes(一次读取打开三个分块并补全两个被拆分的帧;两者都从暂存缓冲区完整转发) |
协议核心测试通过 CoreHarness 驱动 VMessCore,它像运行时一样重新提交未消费的尾部字节,并使用真实的客户端 codec 生成线上字节,因此每个协议核心测试同时也是 codec 测试。
protocols/tests/pipeline/vmess.rs 在真实运行时中通过回环 socket 运行服务端协议核心,对端是运行在客户端运行时中的客户端 codec。运行 cargo test -p etemenanki-protocols --test pipeline vmess。
| 测试 | 覆盖内容 |
|---|---|
new_server_vs_new_client_tcp |
先用 AES-128-GCM 加填充、再用不带填充的 ChaCha20-Poly1305 回显 100 000 字节;干净的半关闭,其后没有任何数据 |
new_server_vs_new_client_udp |
经 UDP 回显服务器回显一个短数据包和一个 4000 字节的数据包,回复的来源报告为请求头中的目标 |
app/tests/integration/e2e_xray_vmess.rs 和 app/tests/integration/e2e_xray_mux.rs 让 etemenanki-app 二进制与从参考源码树构建的 Xray 对接运行。它们需要 go;没有 go 或构建失败时,会打印一行 SKIP: 并通过。运行 cargo test -p etemenanki-app --test integration vmess。
| 测试 | 设置 |
|---|---|
app_server_vmess_grpc_xray_client_tls |
app 服务端走 gRPC 和 TLS,Xray 客户端使用 aes-128-gcm |
app_server_vmess_ws_xray_client_early_data_plain |
app 服务端走带 early data 的 WebSocket,Xray 客户端 |
app_client_vmess_ws_xray_server_early_data_plain |
app 客户端(开启全局填充)对接 Xray 服务端 |
vmess_mux_tcp_single_stream |
开启 mux 的 Xray 客户端,app 服务端;mux 帧位于分块流内部 |
vmess_mux_payload_spans_both_framings |
64 KiB,超过一个分块和一个 mux 数据块,因此两层帧各自独立拆分 |
vmess_xudp_datagram_roundtrip |
在 VMess mux 承载连接上通过 XUDP 传输 UDP |