跳转到内容

VMess:线格式与协议核心

源码文件:26 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/vmess/mod.rs
  • Etemenanki/protocols/src/vmess/protocol.rs
  • Etemenanki/protocols/src/vmess/framing.rs
  • Etemenanki/protocols/src/vmess/session.rs
  • Etemenanki/protocols/src/vmess/core.rs
  • Etemenanki/protocols/src/vmess/codec.rs
  • Etemenanki/protocols/src/vmess/aead.rs
  • Etemenanki/protocols/src/vmess/keys.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/protocols/tests/unit/vmess/protocol.rs
  • Etemenanki/protocols/tests/unit/vmess/framing.rs
  • Etemenanki/protocols/tests/unit/vmess/codec.rs
  • Etemenanki/protocols/tests/unit/vmess/core.rs
  • Etemenanki/protocols/tests/unit/mux/demux.rs
  • Etemenanki/protocols/tests/pipeline/vmess.rs
  • Etemenanki/app/tests/integration/e2e_xray_vmess.rs
  • Etemenanki/app/tests/integration/e2e_xray_mux.rs
  • Etemenanki/app/tests/support/mod.rs
  • katana/src/outbound/mod.rs
  • katana/src/config.rs
  • katana/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,因为承载连接本身是一条流;每个子流各自携带自己的网络类型。

protocols/src/vmess/protocol.rs
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。

  1. 下限。 少于 42 字节(38 个固定字节加校验和)时失败,报 vmess: request header too short。

  2. 校验和。 最后四个字节必须等于它之前所有字节的 fnv1a32,否则解析器报 vmess: request header checksum mismatch。校验和在读取任何字段之前检查。

  3. 版本。 第 0 字节必须是 VERSION;其他值报 vmess: unsupported version <n>。

  4. 选项。 RequestOptions::from_wire 拒绝未启用分块掩码的全局填充:vmess: global padding negotiated without chunk masking。

  5. 安全类型。 第 35 字节的低 4 位必须是 3 或 4。Security::from_byte 对其他值报 vmess: unsupported security type <n>,这涵盖了 Xray 的 none(5)、zero(6)以及旧版取值。

  6. 命令。 第 37 字节必须是 0x01、0x02 或 0x03;Command::from_byte 对其他值报 vmess: unsupported command <n>。

  7. 地址。 对 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 请求跳过这一步。

  8. 精确长度。 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 字节。

protocols/src/vmess/protocol.rs
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。

请求头第 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)
protocols/src/vmess/protocol.rs
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
protocols/src/vmess/protocol.rs
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 使用它。

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 识别它,两种读取方式都会据此报告流结束。

一个方向的编码器和解码器由相同的密钥、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)。
protocols/src/vmess/framing.rs
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)。

运行时按传输层产生的任意片段交付线上字节,一个分块可能跨越两次读取。对它的长度字段解码两次会使 SHAKE 密钥流推进两次,让该方向永久失去同步。ChunkDecoder 在两次调用之间保存已解码的块头:

protocols/src/vmess/framing.rs
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:密钥与认证。

protocols/src/vmess/session.rs
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) 构建选项,因此通过这里构建的客户端总是启用分块掩码。

protocols/src/vmess/core.rs
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>。

协议核心等到 16 个字节到齐,然后询问验证器。未命中——无论是未知用户、时间戳超出窗口还是重放的 ID——都会失败,错误类型为 io::ErrorKind::PermissionDenied,报 vmess: unknown user or invalid auth id。命中则进入 Header,并恰好消费 16 个字节。

在整个封套到齐之前,aead::open_vmess_aead_header_slice 返回 Ok(None),协议核心在此期间不消费任何字节。封套打开后,协议核心:

  1. 用 parse_request_header 解析明文;
  2. 用 chunk_streams 构建两条分块流,并把请求方向的流包装进 ChunkDecoder;
  3. 用 response_header 封装响应头,并在该发送之前一直保存在 response 中;
  4. 用目标、用户和 source 构建 Flow;
  5. 按命令分支(见下表),并返回封套长度。

随后运行时会对剩余字节再次调用,因此同一次读取中到达的请求体分块会在下一个状态中处理。

命令 下一状态 何时写入响应头 何时打开出站 进入的阶段
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 帧边界相互独立:一个分块可以容纳多个帧,一个帧也可以跨越多个分块。一个字节事件可能打开多个分块,协议核心把它们的全部明文范围一次性交给解复用器:

protocols/src/mux/demux.rs
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),这是针对提供的空间少于其承诺的运行时的错误。遵守约定的运行时永远不会触发它。

protocols/src/vmess/codec.rs 把客户端实现为两个 codec,它们共享一个 Session:封装后的请求头、请求方向的 ChunkStream、作用于响应流的 ChunkDecoder、ResponseSession 和一个 replied 标志。

protocols/src/vmess/codec.rs
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。

开启全局填充。 两个 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 测试。