VLESS
源码文件:29 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/vless/mod.rsEtemenanki/protocols/src/vless/protocol.rsEtemenanki/protocols/src/vless/validator.rsEtemenanki/protocols/src/vless/config.rsEtemenanki/protocols/src/vless/core.rsEtemenanki/protocols/src/vless/codec.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/mux/mod.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/protocols/src/mux/frame.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/concepts/src/core.rsEtemenanki/concepts/src/client.rsEtemenanki/concepts/src/runtime.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/serve.rsEtemenanki/protocols/tests/unit/vless/protocol.rsEtemenanki/protocols/tests/unit/vless/validator.rsEtemenanki/protocols/tests/unit/vless/core.rsEtemenanki/protocols/tests/unit/vless/codec.rsEtemenanki/protocols/tests/unit/mux/demux.rsEtemenanki/protocols/tests/pipeline/vless.rsEtemenanki/app/tests/integration/e2e_xray.rsEtemenanki/app/tests/integration/e2e_xray_mux.rsEtemenanki/app/tests/integration/e2e_sniff.rskatana/src/inbound.rs
VLESS 是 etemenanki-protocols 中最精简的协议:一个请求头,写明用户、命令和目标;一个两字节的响应;之后就是流的原始字节。它本身不做任何加密,机密性由下层传输提供(TLS、WebSocket over TLS、gRPC over TLS)。该模块位于 protocols/src/vless/,分三部分:线上格式的基础原语、作为 sans-I/O 协议核心(core)的服务端(VlessCore),以及作为两个 sans-I/O codec 的客户端(VlessStream、VlessDatagram)。
本页面向修改该模块的贡献者。它给出精确的线上布局,按事件逐一讲解 VlessCore 状态机,解释为什么用户表以处理后的 UUID 为键,并列出固定每项行为的测试。协议核心所接入的通用机制(ProxyCoreDecode、effect、per-connection 运行时)在文末链接的概念页中介绍。
| 本模块负责 | 交给其他部分 |
|---|---|
| 从不断增长的字节切片中解析并校验请求头:版本、addons、命令、端口在前的地址。 | 读取 socket、缓冲、定时器和拨号:由 concepts/src/runtime.rs 中的 per-connection 运行时负责。 |
用 Validator<T> 认证 16 字节的用户 id,并把配置中的 UUID 和 payload 附加到流上。 |
决定流去往何处:由应用的 connector(连接器)和路由器负责。 |
| 原样中继 TCP 流,出站连接建立后才发送应答。 | 机密性和服务端认证:由 TLS、WebSocket 和 gRPC 传输层负责。 |
| 把 UDP 关联中继到请求头指定的唯一目标,双向都使用 2 字节长度分帧。 | mux.cool 分帧本身:protocols/src/mux/demux.rs 中的 Demux,与 Trojan 和 VMess 共用。 |
把 mux 承载连接(命令 0x03)交给 Demux。 |
嗅探规则:protocols/src/sniff/ 中的收集器。 |
| 在客户端编码请求、解码响应。 | 配置解析:由 etemenanki-app 和 katana 构建 Validator 和 codec。 |
XTLS flow(Vision)和 reverse 命令不在支持范围内。带任何 addons 的请求都会被拒绝(见校验顺序);etemenanki-app 遇到配置中的 flow 键会报 unknown field `flow`, expected `id` ,因为 app/src/config.rs 中的 IdUser 是 deny_unknown_fields。如果面板为某个节点设置了 VLESS flow,katana 会在 build_protocol(src/inbound.rs)中拒绝该节点,报错 node requests kernel-unsupported feature: VLESS XTLS flow。
| 文件 | 内容 |
|---|---|
protocols/src/vless/mod.rs |
重新导出 VlessCore、VlessStream、VlessDatagram、VlessServerConfig、Command、RequestHeader、Validator。 |
protocols/src/vless/protocol.rs |
常量、Command、RequestHeader、切片解析函数(parse_request_header、parse_response_header、parse_length_packet)、编码函数,以及异步的参考读写实现。 |
protocols/src/vless/validator.rs |
Validator<T>:以处理后 UUID 为键的用户表。 |
protocols/src/vless/config.rs |
VlessServerConfig<T>:用户列表,以及构建用户表的 validator()。 |
protocols/src/vless/core.rs |
VlessCore<T>:服务端状态机,实现了 ProxyCoreDecode。 |
protocols/src/vless/codec.rs |
VlessStream(ProxyCoreEncode)和 VlessDatagram(ProxyCoreEncodeDatagram)。 |
所有多字节整数均为大端序。布局遵循 Xray 的 proxy/vless/encoding。
| 偏移 | 字段 | 长度 | 含义 |
|---|---|---|---|
| 0 | version | 1 | 必须为 0x00(VERSION)。 |
| 1 | uuid | 16 | 用户 id,即线上的原始字节。 |
| 17 | addons length | 1 | 随后的 protobuf addons 长度。必须为 0x00:非零值意味着 XTLS flow,会被拒绝。 |
| 18 | command | 1 | 0x01 TCP(CMD_TCP),0x02 UDP(CMD_UDP),0x03 mux(CMD_MUX)。其他值一律拒绝。 |
| 19 | address | 5 到 259 | 先是端口,再是带类型的地址(见下表):IPv4 为 7 字节,IPv6 为 19 字节,n 字节的域名为 4 + n 字节。CMD_MUX 没有此字段:请求头在偏移 19 处结束。 |
最长的请求头为 REQUEST_HEADER_MAX = 1 + 16 + 1 + 1 + AddressCodec::MAX_LEN = 278 字节,其中 AddressCodec::MAX_LEN = 1 + 1 + 255 + 2 = 259。
VLESS 使用 VMess 风格的编解码器 ADDR = AddressCodec::VMESS(protocols/src/helpers/address.rs):端口在前,类型字节的取值也与 SOCKS 不同。
| 字段 | 长度 | 含义 |
|---|---|---|
| port | 2 | 目标端口。 |
| type | 1 | 0x01 IPv4,0x02 域名,0x03 IPv6。其他值报错 unknown address type: N。 |
| address | 4、1 + n 或 16 | IPv4 的各个字节;一个长度字节加 n 字节域名;或 IPv6 的各个字节。 |
域名由 parse_domain_bytes 解码。以数字或 [ 开头、且(去掉方括号后)能解析为 IP 字面量的域名,会被折回 Remote::IpAddr。其余情况必须是非空 UTF-8,且只包含 ASCII 字母、数字、-、. 和 _;否则解析失败,报错 empty domain name、non-utf8 domain 或 invalid domain name: …(均为 InvalidData)。这种折回对嗅探很重要,因为嗅探只对 IP 目标运行。
| 偏移 | 字段 | 长度 | 含义 |
|---|---|---|---|
| 0 | version | 1 | 0x00。 |
| 1 | addons length | 1 | 随后的 addons 长度。 |
| 2 | addons | n | 客户端直接跳过。 |
服务端总是发送 RESPONSE_HEADER = [VERSION, 0]。客户端解析函数 parse_response_header 接受并跳过非空的响应 addons,但拒绝 0 以外的任何版本。请求 addons 被拒绝是因为它们携带 XTLS flow;响应 addons 则直接跳过。
UDP 分帧
Section titled “UDP 分帧”CMD_UDP 请求之后,双向都传输带长度前缀的数据包,不含逐包地址:
| 字段 | 长度 | 含义 |
|---|---|---|
| length | 2 | payload 长度,0 到 65535。 |
| payload | length | 一个数据报。 |
所有上行数据包都发往请求头中的目标;所有下行数据包都被客户端视为来自该目标。要在一条连接上访问多个 UDP 对端,需要使用 mux 承载连接(XUDP,见 mux.cool 与 XUDP)。
Mux 请求
Section titled “Mux 请求”CMD_MUX 请求长 19 字节:version、uuid、addons length 和 command,没有地址。parse_request_header 用 mux_destination()(protocols/src/mux/mod.rs)合成目标:域名 MUX_ADDRESS = "v1.mux.cool",端口 0,网络 TCP。这个目标永远不会被拨号;它是日志行或路由规则看到的承载连接目标。mux.cool 帧从偏移 19 开始。
线上格式原语
Section titled “线上格式原语”pub const REQUEST_HEADER_MAX: usize = 1 + 16 + 1 + 1 + AddressCodec::MAX_LEN;pub const RESPONSE_HEADER: [u8; 2] = [VERSION, 0];pub const VERSION: u8 = 0;pub const CMD_TCP: u8 = 1;pub const CMD_UDP: u8 = 2;pub const CMD_MUX: u8 = 3;pub const ADDR: AddressCodec = AddressCodec::VMESS;
pub enum Command { Tcp, Udp, Mux,}
impl Command { pub fn network(self) -> DialNetwork;}
pub struct RequestHeader { pub uuid: [u8; 16], pub command: Command, pub destination: Destination,}
pub fn encode_request_header( uuid: &[u8; 16], command: Command, destination: &Destination,) -> BytesMut;pub fn parse_request_header(buf: &[u8]) -> io::Result<Option<(RequestHeader, usize)>>;pub fn parse_response_header(buf: &[u8]) -> io::Result<Option<usize>>;pub fn parse_length_packet(buf: &[u8]) -> Option<(Range<usize>, usize)>;pub fn encode_length_packet(payload: &[u8]) -> Bytes;Command::network 把 Tcp 和 Mux 映射为 DialNetwork::Tcp(mux 承载连接本身是字节流,其子流各自携带自己的网络类型),把 Udp 映射为 DialNetwork::Udp。RequestHeader::uuid 是线上出现的原始 id;配置中的 UUID 来自校验器。
三个 parse_* 函数是协议核心和 codec 实际使用的形式。它们接收一个可能仍在增长的缓冲区的前部,数据不足时返回 Ok(None),调用方因此不消费任何字节,等待更多数据。输入不足通过 need_more(protocols/src/helpers/parse.rs)检测:它把带边界检查的 take/take_array 辅助函数返回的 UnexpectedEof 转为 None,其他错误照常向上传递。
模块还保留了直接读写 AsyncRead/AsyncWrite 的异步参考实现。管线中没有任何地方调用它们;单元测试把它们用作第二个独立的解码器:
pub async fn write_request_header<W: AsyncWrite + Unpin>( writer: &mut W, uuid: &[u8; 16], command: Command, destination: &Destination,) -> io::Result<()>;pub async fn read_request_header<R: AsyncRead + Unpin>( reader: &mut R,) -> io::Result<RequestHeader>;pub async fn write_response_header<W: AsyncWrite + Unpin>(writer: &mut W) -> io::Result<()>;pub async fn read_response_header<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<()>;pub async fn read_length_packet<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<Bytes>;校验器与服务端配置
Section titled “校验器与服务端配置”fn process_uuid(mut id: [u8; 16]) -> [u8; 16];
pub struct Validator<T> { users: HashMap<[u8; 16], (Uuid, Arc<T>)>,}
impl<T> Validator<T> { pub fn new() -> Self; pub fn add(&mut self, id: Uuid, data: Arc<T>); pub fn get(&self, id: &[u8; 16]) -> Option<(Uuid, Arc<T>)>; pub fn len(&self) -> usize; pub fn is_empty(&self) -> bool;}pub struct VlessServerConfig<T> { pub users: Vec<(Uuid, Arc<T>)>,}
impl<T> VlessServerConfig<T> { pub fn validator(&self) -> Validator<T>;}T 是每个用户的 payload,以 NetworkUser::user_data 的形式随每条流传递。etemenanki-app 使用 ();katana 使用每用户的 UserTag(src/inbound.rs 中的 Validator<UserTag>)。Clone 和 Default 是手写实现,不要求 T: Clone:payload 始终在 Arc 后面。用户表每个入站只构建一次,包进 Arc,由所有连接的协议核心只读共享,因此查找不需要加锁。
处理后的 UUID
Section titled “处理后的 UUID”process_uuid 把 UUID 的第 6、7 字节置零。Validator::add 以配置 UUID 的处理后形式存储每个用户,Validator::get 在查找前也会先处理请求中的 id:
fn process_uuid(mut id: [u8; 16]) -> [u8; 16] { id[6] = 0; id[7] = 0; id}这是 Xray proxy/vless/validator.go 中 ProcessUUID 的移植,Xray 的 MemoryValidator 也以同样的方式为用户建键。保持一致可以让认证结果与 Xray 服务端完全相同:Xray 能匹配到某个用户的客户端 id,在这里也能匹配到。第 6 字节包含 UUID 的版本半字节,因此键不依赖版本位。对贡献者而言,这意味着:
get返回的是配置中的Uuid及其 payload,而不是请求中的字节。协议核心把这个Uuid放入UserAuthorization::Uuid,把 payload 放入NetworkUser::user_data,因此即使客户端的第 6、7 字节不同,路由和日志看到的也是配置中的 id,katana 的流量计费(读取UserTagpayload)也会记到配置中的那个用户上。- 两侧都必须调用
process_uuid。如果用原始 UUID 作为 map 的键,或者用请求的原始字节查找,第 6、7 字节不同的客户端就会悄无声息地认证失败。
VlessCore
Section titled “VlessCore”pub struct VlessCore<T> { validator: Arc<Validator<T>>, sniff: bool, source: Option<IpAddr>, timing: Timing, prefix: SniffPrefix, state: State<T>,}
impl<T> VlessCore<T> { pub const BUF_SIZE: usize = 16 * 1024;
pub fn new(validator: Arc<Validator<T>>, sniff: bool, source: Option<IpAddr>) -> Self; pub fn is_established(&self) -> bool;}
impl<T: Send + Sync + 'static> ProxyCoreDecode for VlessCore<T> { type Key = FlowKey; type Target = Flow<T>; type Error = io::Error; type TransportAddr = ();
const STAGING_RESERVE: usize = 272 + downlink_overhead(Self::BUF_SIZE); const MAX_DATAGRAM: usize = 8192;
fn handle( &mut self, event: Event<'_, Self>, fx: &mut Effects<'_, Self>, ) -> Result<usize, io::Error>;
fn held(&self) -> &[u8];}私有的状态枚举就是整个状态机:
enum State<T> { Handshake, Sniff(Flow<T>), Tcp { relay: Passthrough<FlowKey>, replied: bool, }, Udp { flow: Flow<T>, opened: bool, }, Mux(Demux<T>), Done,}协议核心组合 protocols/src/core/mod.rs 中的共享组件,而不是重新实现它们:
| 组件 | 在 VlessCore 中的作用 |
|---|---|
Timing |
每个阶段唯一的 deadline:HANDSHAKE_TIMEOUT、SNIFF_TIMEOUT 或 RELAY_IDLE_TIMEOUT。每个字节事件(Transport、Outbound、Datagram)开始处都会调用 touch。is_established() 委托给它,在 Phase::Relay 和 Phase::Closing 中为 true。 |
SniffPrefix |
目标为 IP 的 TCP 流的前几个 payload 字节,在流打开前一直暂存。 |
Passthrough<FlowKey> |
唯一那条 TCP 出站的半关闭记录,键为 FlowKey::Direct。 |
Demux<T> |
CMD_MUX 承载连接的 mux.cool 解复用器;其子流的键为 FlowKey::Sub(SubKey)。 |
held() 在 State::Mux 中返回 demux 暂存的缓冲区,其他状态返回嗅探前缀,因此 Effect::ForwardHeld 的范围总是对应当前有效的那个缓冲区。
| 常量 | 值 | 原因 |
|---|---|---|
VlessCore::BUF_SIZE |
16 KiB | 运行时的读缓冲区和 staging 缓冲区。一个 UDP 帧在 2 字节长度之后最多携带 MAX_DATAGRAM 字节;一次最多 BUF_SIZE 的 mux 下行读取还需要为它的 Keep 帧头留出空间。 |
STAGING_RESERVE |
272 + downlink_overhead(16384) = 808 |
单个事件在回显自身 payload 之外最多需要 stage 的字节:响应头、UDP 帧的长度前缀,或一次出站读取被拆分成的多个 mux Keep 帧头。 |
downlink_overhead(read_size) |
read_size.div_ceil(MAX_DATA_LEN) * FRAME_OVERHEAD_MAX = 2 × 268 |
每个 MAX_DATA_LEN(8 KiB)分片一个 Keep 帧头;FRAME_OVERHEAD_MAX = 2 + 4 + 1 + AddressCodec::MAX_LEN + 2 = 268(protocols/src/mux/frame.rs)。 |
MAX_DATAGRAM |
8192 | 从 trait 默认值 4096 调高。只有 staging 中空闲空间达到 STAGING_RESERVE + MAX_DATAGRAM 时,运行时才会轮询数据报出站,这在 16 KiB 内放得下;每个数据包最多读入这么多字节,与内核 recv 一样,更长的下行数据包会被截断。 |
运行时的约定(concepts/src/core.rs → ProxyCoreDecode::STAGING_RESERVE)是:除 Deadline 外,每个事件到达时 staging 至少有这么多空闲空间,出站字节事件还要再加上 payload 长度。因此协议核心可以把 fx.stage 或 staging.reserve 失败视为 bug,返回 staging_full()(staging room below the core's declared reserve)。
客户端 codec
Section titled “客户端 codec”const RESERVE: usize = REQUEST_HEADER_MAX;
pub struct VlessStream { header: BytesMut, replied: bool,}
impl VlessStream { pub fn new(uuid: &[u8; 16], dest: &Destination) -> Self;}
pub struct VlessDatagram { header: BytesMut, target: Destination, replied: bool,}
impl VlessDatagram { pub fn new(uuid: &[u8; 16], target: &Destination) -> Self;}两者都实现 ProxyCoreEncodeHandshake,其中 type Target = Destination(运行时要拨号的上游 VLESS 服务器)、type Error = io::Error、const STAGING_RESERVE: usize = RESERVE,即 278。VlessStream 另外实现 ProxyCoreEncode,VlessDatagram 另外实现 ProxyCoreEncodeDatagram:
fn start(&mut self, out: &mut Staging<'_>) -> Result<Handshake, Self::Error>;fn reply(&mut self, wire: &mut [u8], out: &mut Staging<'_>) -> Result<Reply, Self::Error>;fn finish(&mut self, out: &mut Staging<'_>) -> Result<(), Self::Error>;
fn seal(&mut self, plain: &[u8], out: &mut Staging<'_>) -> Result<usize, Self::Error>;fn open(&mut self, wire: &mut [u8]) -> Result<Opened, Self::Error>;
fn seal_to( &mut self, plain: &[u8], to: &Destination, out: &mut Staging<'_>,) -> Result<Option<()>, Self::Error>;fn open_from(&mut self, wire: &mut [u8]) -> Result<OpenedFrom, Self::Error>;etemenanki-app 在 app/src/outbound/mod.rs 中按流选择 codec:destination.network 为 DialNetwork::Udp 的流使用 VlessDatagram::new(&uuid, &flow.destination),其他流使用 VlessStream::new(&uuid, &flow.destination)。两者都运行在 ProxyClient<VLESS_BUF, VlessStream, VlessDatagram> 中,VLESS_BUF = 16 * 1024。每条流各自打开一条上游连接;客户端从不发送 CMD_MUX。
服务端状态机
Section titled “服务端状态机”stateDiagram-v2 [*] --> Handshake Handshake --> Tcp: CMD_TCP,嗅探关闭或目标为域名 Handshake --> Sniff: CMD_TCP,嗅探开启且目标为 IP Handshake --> Udp: CMD_UDP,已 stage 应答 Handshake --> Mux: CMD_MUX,已 stage 应答 Handshake --> Done: TransportEof Sniff --> Tcp: 得出判定、deadline 到期或 TransportEof Tcp --> Tcp: Connected 只 stage 一次应答 Udp --> Done: TransportEof 或出站已失效 Mux --> Done: TransportEof Done --> [*]
Tcp 状态本身通过 Passthrough 结束:两个方向都关闭后、出站失效时,或 Timing 报告中继空闲时,连接结束。Handshake 中的校验错误不会转到 Done;handle 返回 Err,由运行时拆除连接。
parse_request_header 在每个字段的字节一到达就立即检查,因此格式错误的输入在整个请求头到齐之前就会失败,而用户查找只在完整且格式正确的请求头上进行。
flowchart TB
v{"字节 0 == 0?"} -- 否 --> e1["InvalidData: invalid vless request version: N"]
v -- 是 --> u["字节 1..17:uuid"]
u --> a{"字节 17 == 0?"}
a -- 否 --> e2["Unsupported: vless addons (xtls flow) are not supported"]
a -- 是 --> c{"字节 18 属于 1、2、3?"}
c -- 否 --> e3["InvalidData: invalid vless command: N"]
c -- "3(mux)" --> m["mux_destination(),已用 19 字节"]
c -- "1 或 2" --> ad["从字节 19 起的端口在前地址"]
ad --> look["Validator::get(uuid)"]
m --> look
look -- None --> e4["PermissionDenied: invalid vless request user id"]
look -- "Some(uuid, data)" --> ok["Flow::new(destination, user, source)"]
上述任一错误都由 handle 返回。此时协议核心还没有 stage 任何内容,因此连接关闭前客户端收不到任何响应字节。请求头不完整时,on_transport 返回 Ok(0),运行时把这些字节留在读缓冲区中;第一个这样的事件会通过 Timing::touch 启动 HANDSHAKE_TIMEOUT。
响应何时发出
Section titled “响应何时发出”| 命令 | stage 响应头的时机 | 原因 |
|---|---|---|
CMD_TCP |
在第一个 Event::Connected 时,由 replied 保证只发一次。 |
拨号失败时连接直接关闭,从不应答,因此客户端会在收到任何响应头之前看到流结束。 |
CMD_UDP |
认证通过后立即发出,在同一次 handle 调用中。 |
出站 socket 在第一个数据包到来时才惰性打开,没有需要等待的连接过程。 |
CMD_MUX |
认证通过后立即发出,此时还没有任何子流。 | 每个子流有自己的出站和各自的成功或失败结果,通过 mux 帧报告。 |
VLESS 客户端发送 payload 前不会等待响应(客户端 codec 的 start 返回 Handshake::Done),因此第一批 payload 字节通常与请求头一起到达,或紧随其后。这正是能在拨号前进行嗅探的原因。
sequenceDiagram participant C as VlessStream(客户端运行时) participant S as VlessCore(服务端运行时) participant O as 出站 C->>S: 请求头 + 首段 payload Note over S: 解析,Validator::get,Flow::new S->>O: Effect::Open (FlowKey::Direct) S->>O: Effect::Forward payload(等待连接建立) O-->>S: Event::Connected S-->>C: stage RESPONSE_HEADER [0, 0] O-->>S: Event::Outbound 字节 S-->>C: 原样 stage Note over C: open() 把 2 字节作为空帧消费,之后都是明文 C->>S: TransportEof S->>O: Effect::Shutdown(半关闭) O-->>S: Event::OutboundEof S-->>C: Effect::ShutdownTransport,然后 Effect::Finish
请求头之后,State::Tcp 中的 on_transport 通过 Passthrough::on_transport 转发整个切片(对 0..data.len() 发出 Effect::Forward),Event::Outbound 通过 Passthrough::on_outbound stage 整个切片。两者都会先清空嗅探前缀:此时引用它的 ForwardHeld 已经执行完毕,这由运行时的 pin 规则保证(只要还有引用暂存范围的 effect 未完成,就不会投递字节事件)。
Event::ConnectFailed 和 Event::OutboundError 调用 Passthrough::on_outbound_gone,它会推入 ShutdownTransport 和 Finish。已 stage 的字节仍会发送完毕。
当 sniff 开启且目标为 IP(protocols/src/sniff/mod.rs 中的 worth_sniffing)时,TCP 请求进入 State::Sniff(flow) 和 Phase::Sniff,并启动 SNIFF_TIMEOUT(300 ms)。每个传输层事件把字节推入 SniffPrefix,最多到 SNIFF_LIMIT(4 KiB),并且只消费实际取走的部分。满足以下任一条件时流被打开(open_sniffed):
- 收集器返回
Verdict::More以外的判定:Found(某个嗅探器识别出了这些字节)或Exhausted(已收集 4 KiB 仍无匹配)。没有任何嗅探器能识别的 payload 会让流一直等待,直到出现上述判定之一或 deadline 到期; - deadline 触发(
Expired::Sniff); - 客户端半关闭(
TransportEof),此时打开之后立即应用半关闭。
open_sniffed 把 SniffPrefix::result() 复制到 flow.sniffed,推入 Effect::Open,如果收集到了数据,再对 0..held 推入 Effect::ForwardHeld,然后进入 Phase::Relay。目标为域名时跳过嗅探,立即打开。VLESS 协议核心从不嗅探 UDP 关联和 mux 承载连接;Demux 会把 sniff 标志应用到它自己的子流上。
UDP 关联
Section titled “UDP 关联”sequenceDiagram participant C as VlessDatagram(客户端运行时) participant S as VlessCore participant O as UDP 出站 C->>S: 请求头(CMD_UDP,目标 T) S-->>C: stage RESPONSE_HEADER C->>S: len + 数据包 1,len + 数据包 2 S->>O: Effect::Open(仅在第一个完整帧时) S->>O: Effect::SendTo T(数据包 1) S->>O: Effect::SendTo T(数据包 2) O-->>S: Event::Datagram(其 from 不会被编码) S-->>C: stage len + payload Note over C: open_from() 把每个回包都标记为来自 T C->>S: TransportEof S->>O: Effect::Close,ShutdownTransport,Finish
在 State::Udp 中,on_transport 用 parse_length_packet 循环处理所有完整帧。每一帧推入一个 Effect::SendTo { key: FlowKey::Direct, to: flow.destination.clone(), range },其中 range 指向事件切片内的 payload,因此转发数据包无需复制。to 始终是请求头中的目标。Effect::Open 在第一个 SendTo 之前才惰性推入,由 opened 记录。末尾不完整的帧不会被消费,留在读缓冲区中等待。
反方向上,Event::Datagram 在 staging 中预留 len + 2 字节,写入大端序长度,再复制 payload。数据包的 from 不会被编码,线上格式没有对应字段。长度超过 u16::MAX 的 payload 会报错 vless: packet exceeds a u16,但运行时永远不会投递这样的数据包:它读取每个数据包时最多读 MAX_DATAGRAM 字节。
finish_udp(在 TransportEof 时调用)只在出站已打开时推入 Effect::Close,然后推入 ShutdownTransport 和 Finish。ConnectFailed 和 OutboundError 以同样方式结束关联,但不推入 Close。Event::SendFailed 只丢弃那一个数据包,记录一行 debug 日志(vless: packet to … dropped: …),关联保持不变。
Mux 承载连接
Section titled “Mux 承载连接”对于 CMD_MUX,协议核心 stage 响应,转到 State::Mux(Demux::new(flow, self.sniff)) 并进入 Phase::Relay。此后:
Event::Transport调用demux.feed(data, 0, fx)。VLESS 是不加密的承载,因此Demux会把末尾不完整的帧留在运行时的读缓冲区中,而不是复制出来。随后,当take_out()非空时,协议核心将其 stage。每次Demux调用在写入前都会先清空out,因此feed之后out中只有上行拒绝会话时产生的End帧:超出MAX_SESSIONS或 id 已被占用的New,或者发给 demux 不认识的会话的Keep数据。- 键为
FlowKey::Sub的Event::Outbound、Event::Datagram、Event::OutboundEof、Event::ConnectFailed和Event::OutboundError交给on_sub,它调用Demux::on_outbound、on_datagram或on_outbound_gone,并原地 stagedemux.out(),不将其取走。由于下一次调用会先清空out,下一个上行不会再次 stage 这些下行帧:每一帧只发给客户端一次。 Event::TransportEof调用demux.on_transport_eof,然后推入ShutdownTransport和Finish。
承载连接自身的流把用户和来源地址带入每个子流。帧格式、MAX_SESSIONS(每个承载连接 256 个子流)和 XUDP 在 mux 页面中介绍。
各状态下的事件处理
Section titled “各状态下的事件处理”| 事件 | Handshake |
Sniff |
Tcp |
Udp |
Mux |
|---|---|---|---|---|---|
Transport |
解析;数据不足时 Ok(0) |
收集前缀 | 原样 Forward |
帧转为 SendTo |
Demux::feed |
Outbound |
忽略 | 忽略 | 原样 stage | 忽略 | on_sub(子流键) |
Datagram |
忽略 | 忽略 | 忽略 | 加长度前缀后 stage | on_sub(子流键) |
Connected |
– | – | 只 stage 一次应答 | – | – |
ConnectFailed、OutboundError |
– | – | on_outbound_gone |
ShutdownTransport、Finish |
on_sub 失效处理 |
OutboundEof |
– | – | on_outbound_eof |
– | on_sub 失效处理 |
TransportEof |
Finish |
打开,然后半关闭 | on_transport_eof |
finish_udp |
Demux EOF,Finish |
Deadline |
Err(TimedOut) |
用已暂存的数据打开 | Finish(空闲) |
Finish(空闲) |
Finish(空闲) |
SendFailed |
记录日志,丢弃数据包 | 同左 | 同左 | 同左 | 同左 |
TransportDatagram 和 TransportSendFailed 返回 Ok(0):VLESS 只运行在字节流传输层之上(type TransportAddr = ())。
客户端 codec
Section titled “客户端 codec”VlessStream
Section titled “VlessStream”sequenceDiagram participant P as 明文侧 participant R as ProxyClientRuntime participant U as 上游服务器 R->>U: 拨号 R->>R: start():stage 请求头,Handshake::Done P->>R: poll_write(plain) R->>U: 请求头 + seal(plain) 原样发送 U-->>R: 0x00 0x00 + data R->>R: open():Frame,消费 2 字节,plain 0..0 R->>R: open():Frame 覆盖整个切片 R-->>P: poll_read(data)
new用encode_request_header(uuid, Command::Tcp, dest)编码一次请求头;start把它放入 staging、清空它并返回Handshake::Done,因此明文可以在同一次写入中紧随其后。- 对于
start返回Done的 codec,reply永远不会被调用;如果被调用,它返回错误vless: the response header is read with the first frame。 open先解析响应头。响应头不完整时返回Opened::NeedMore;完整后返回一个空帧(plain: 0..0),消费响应头和所有响应 addons。此后每个非空切片都是一帧明文。seal原样复制明文;finish不 stage 任何内容,因为半关闭由线路本身的 EOF 表达。Staging::put失败时返回vless: staging room below the declared reserve。
VlessDatagram
Section titled “VlessDatagram”new为target编码一个Command::Udp请求头,并保留一份target的克隆。seal_to忽略to参数:关联绑定在请求头目标上,线上格式也没有逐包地址。payload 长于u16::MAX或放不进提供的空间时,它返回Ok(None),不 stage 任何内容。客户端运行时把None转为InvalidInput(codec refused a packet that fits its reserve)。大于客户端缓冲区的数据包会更早地在ProxyClientRuntime::make_room中失败,报错frame larger than the client runtime's buffer。open_from把响应头作为一个没有来源(None)的空帧消费,运行时会跳过它。之后每个完整的长度前缀帧都以Some(self.target.clone())作为来源返回。
| 不变量 | 机制 | 由哪些测试固定 |
|---|---|---|
请求和响应的版本字节都必须为 0。 |
parse_request_header 和 parse_response_header 最先检查字节 0。 |
rejects_bad_version、response_header_rejects_version、response_header_and_length_packets_are_parsed_from_slices(protocols/tests/unit/vless/protocol.rs);stream_codec_consumes_the_response_header_as_an_empty_frame(codec.rs) |
| 带 addons 的请求会被拒绝。 | addons_len != 0 时返回 ErrorKind::Unsupported。 |
rejects_nonempty_addons、request_header_is_parsed_from_a_slice_once_whole |
只接受命令 1、2、3。 |
parse_request_header 中的 match cmd 对其他字节返回 InvalidData。 |
没有专门的 VLESS 测试固定。 |
mux 请求没有地址,字节 19 之后的内容留给 Demux。 |
Command::Mux 返回 mux_destination(),消费 19 字节。 |
mux_command_synthesises_its_destination、mux_request_header_roundtrips、request_header_is_parsed_from_a_slice_once_whole |
| 不完整的请求头不消费任何字节。 | need_more 把输入不足映射为 Ok(None);协议核心返回 Ok(0)。 |
request_header_is_parsed_from_a_slice_once_whole(在 0、1、17、18、19、22 以及差一个字节处截断);tcp_request_opens_and_replies_only_once_connected(前 10 字节) |
| 用户按处理后的 UUID 匹配,按配置中的 UUID 报告。 | add 和 get 都调用 process_uuid;get 返回存储的 Uuid。 |
validator_matches_ignoring_bytes_6_and_7(validator.rs) |
| 未知用户收不到任何响应。 | Validator::get 返回 None,协议核心在 stage 任何内容之前返回 PermissionDenied。 |
unknown_uuid_is_refused(core.rs) |
| TCP 响应只发送一次,且只在出站连接建立之后发送。 | Event::Connected 在 replied 标志保护下 stage RESPONSE_HEADER。 |
tcp_request_opens_and_replies_only_once_connected |
| 连接失败时关闭连接,不发送响应。 | State::Tcp 中的 ConnectFailed 调用 Passthrough::on_outbound_gone,此前没有 stage 任何内容。 |
a_refused_connect_closes_without_a_reply |
| 经嗅探的流以识别出的域名打开,并先转发暂存的前缀。 | open_sniffed 设置 flow.sniffed,然后推入 Open、ForwardHeld 和中继 deadline。 |
sniffing_holds_the_prefix_then_opens_with_the_domain |
| UDP 关联立即应答,在第一个数据包时打开出站,只发往请求头目标,并且不消费不完整的帧。 | 带 opened 标志的 State::Udp;SendTo 始终使用 flow.destination。 |
udp_association_replies_at_once_and_frames_both_ways |
| mux 承载连接立即应答,其子流被解复用。 | Command::Mux stage 应答并交给 Demux。 |
vless_answers_mux_at_once_and_demultiplexes(protocols/tests/unit/mux/demux.rs) |
| 每个 mux 下行帧只 stage 一次。 | 每个写入 out 的 Demux 调用都先清空它,因此 feed 之后其中只有该上行的拒绝帧,绝不会包含 on_sub 之前 stage 的帧。 |
a_downlink_frame_is_not_sent_again_by_the_next_uplink、vless_answers_mux_at_once_and_demultiplexes(protocols/tests/unit/mux/demux.rs) |
| 客户端数据报 codec 把每个数据包都发往固定目标,并把每个回包都报告为来自该目标。 | seal_to 忽略 to;open_from 返回 self.target。 |
datagram_codec_frames_to_the_fixed_target(codec.rs) |
| 协议核心在事件 payload 之外 stage 的字节永远不超过其预留量。 | STAGING_RESERVE 覆盖应答、长度前缀或 downlink_overhead(BUF_SIZE) 的 mux 帧头;不足即 staging_full()。 |
由 new_server_vs_new_client_tcp 和 new_server_vs_new_client_udp(protocols/tests/pipeline/vless.rs)端到端覆盖 |
失败路径与取消
Section titled “失败路径与取消”| 情况 | 结果 |
|---|---|
| 版本、addons、命令或地址错误 | handle 返回 Err(InvalidData 或 Unsupported);运行时以 RuntimeError::Core 结束,应用在 debug 级别记录 vless connection from … ended: …。不发送任何响应。 |
| 未知用户 | PermissionDenied(invalid vless request user id),路径同上。 |
| 客户端在请求头中途停止发送 | 第一个事件已启动 HANDSHAKE_TIMEOUT(10 s);Deadline 返回 client did not complete its request in time(TimedOut)。一个字节都不发的客户端不会产生任何事件:etemenanki-app 在 app/src/serve.rs 的 drive 中用同样的超时监视这种情况,直到 is_established() 变为 true。 |
| 客户端在请求头之前或之中关闭 | Handshake 中的 TransportEof 正常结束,不报错。 |
| TCP 拨号失败 | on_outbound_gone:ShutdownTransport、Finish;不发送响应。 |
| UDP 出站失败 | ShutdownTransport、Finish,不推入 Close。 |
| 单次 UDP 发送失败 | 丢弃该数据包并记录一行 debug 日志;关联保持。 |
| 中继空闲 | 在 RELAY_IDLE_TIMEOUT(300 s)内没有字节事件,Timing::expired 推入 Finish。 |
| 上行帧大于读缓冲区 | 运行时报告 RuntimeError::FrameTooLarge(protocol frame exceeds the read buffer),连接结束。由于 BUF_SIZE = 16 KiB,普通 VLESS 关联上的上行 UDP 帧最多只能携带 16 382 字节 payload,尽管长度字段允许 65 535。 |
| 取消 | 丢弃运行时 future 会同时关闭传输层和所有出站;协议核心不拥有任何 task、channel 或锁,因此没有任何东西会比连接活得更久。 |
| 名称 | 值 | 位置 |
|---|---|---|
VlessCore::BUF_SIZE |
16 KiB | protocols/src/vless/core.rs |
STAGING_RESERVE(服务端) |
808 字节 | protocols/src/vless/core.rs |
MAX_DATAGRAM |
8192 字节 | protocols/src/vless/core.rs |
REQUEST_HEADER_MAX、客户端 STAGING_RESERVE |
278 字节 | protocols/src/vless/protocol.rs、codec.rs |
| UDP 长度前缀 | u16,线上最多 65535 字节;实际上行 16 382、下行 MAX_DATAGRAM |
protocols/src/vless/protocol.rs |
HANDSHAKE_TIMEOUT |
10 s | protocols/src/core/mod.rs |
RELAY_IDLE_TIMEOUT |
300 s | protocols/src/core/mod.rs |
SNIFF_TIMEOUT |
300 ms | protocols/src/sniff/mod.rs |
SNIFF_LIMIT |
4 KiB | protocols/src/sniff/mod.rs |
MAX_SESSIONS(每个承载连接的 mux 子流数) |
256 | protocols/src/mux/demux.rs |
VLESS_BUF(应用客户端运行时缓冲区) |
16 KiB | app/src/outbound/mod.rs |
单元测试通过 #[path] 属性编译进库中,运行方式为 cargo test -p etemenanki-protocols --lib vless。协议核心测试通过 CoreHarness(protocols/src/core/harness.rs)驱动 VlessCore,它记录 effect 和已 stage 的字节,不做任何 I/O。
| 测试 | 文件 | 固定的行为 |
|---|---|---|
request_header_roundtrip_tcp_ipv4、request_header_roundtrip_udp_domain、request_header_roundtrip_ipv6 |
protocols/tests/unit/vless/protocol.rs |
字节布局(偏移 0、1..17、17、18),以及每种地址类型的往返编解码。 |
rejects_bad_version、rejects_nonempty_addons |
同上 | 版本和 addons 的拒绝,以及对应的错误类型。 |
mux_command_synthesises_its_destination、mux_request_header_roundtrips |
同上 | 19 字节的 mux 请求头,不触碰后面的帧。 |
response_header_roundtrip、response_header_skips_addons、response_header_rejects_version |
同上 | 响应头及 addons 跳过。 |
length_packet_roundtrip、multiple_length_packets |
同上 | UDP 分帧。 |
request_header_is_parsed_from_a_slice_once_whole、response_header_and_length_packets_are_parsed_from_slices |
同上 | 切片解析函数在每个截断点都返回 None,报告已消费的字节数,mux 请求头在 19 字节处停止,并拒绝 addons 和错误的响应版本。 |
validator_matches_ignoring_bytes_6_and_7 |
protocols/tests/unit/vless/validator.rs |
基于处理后 UUID 的查找,并返回配置中的 id。 |
tcp_request_opens_and_replies_only_once_connected、a_refused_connect_closes_without_a_reply、unknown_uuid_is_refused |
protocols/tests/unit/vless/core.rs |
TCP 握手、deadline 启动、应答时机以及失败路径。 |
sniffing_holds_the_prefix_then_opens_with_the_domain |
同上 | 嗅探阶段、暂存前缀和 deadline。 |
udp_association_replies_at_once_and_frames_both_ways |
同上 | UDP 应答时机、惰性打开、目标固定、不完整帧和拆除。 |
stream_codec_consumes_the_response_header_as_an_empty_frame、datagram_codec_frames_to_the_fixed_target |
protocols/tests/unit/vless/codec.rs |
两个客户端 codec。 |
vless_answers_mux_at_once_and_demultiplexes |
protocols/tests/unit/mux/demux.rs |
经过 VlessCore 的 mux 承载连接,以及下一个上行向另一个对端发送数据包时不会再次 stage 之前的应答。 |
a_downlink_frame_is_not_sent_again_by_the_next_uplink |
同上 | 像 VlessCore 那样从 out() 原地读取的应答,不会被下一次 feed 再次排入队列。 |
new_server_vs_new_client_tcp、new_server_vs_new_client_udp |
protocols/tests/pipeline/vless.rs |
真实运行时走 loopback:带半关闭的 70 000 字节回显,以及 8 字节和 1500 字节的 UDP 数据包。运行方式为 cargo test -p etemenanki-protocols --test pipeline vless。 |
基于 WebSocket、gRPC 和 TLS 的 app_client_* 和 app_server_* |
app/tests/integration/e2e_xray.rs |
以 VLESS 作为隧道协议,与真实 Xray 二进制双向互通。 |
vless_mux_tcp_single_stream、vless_mux_tcp_concurrent_streams_stay_separate、vless_mux_over_ws_tls、vless_xudp_datagram_roundtrip、xudp_attributes_replies_to_the_right_peer |
app/tests/integration/e2e_xray_mux.rs |
开启 mux 的 Xray 客户端对接 VLESS 服务端。 |
an_http_host_routes_an_ip_addressed_flow 及其他嗅探测试 |
app/tests/integration/e2e_sniff.rs |
经过 VLESS 入站的端到端嗅探。 |
Xray 互通测试会用 go build 从工作区的参考源码树构建 Xray;在没有 Go 的机器上会自行跳过(输出 SKIP: `go` not available; skipping xray interop test),构建失败时也会跳过(SKIP: failed to build xray-core)。