跳转到内容

VLESS

源码文件:29 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/vless/mod.rs
  • Etemenanki/protocols/src/vless/protocol.rs
  • Etemenanki/protocols/src/vless/validator.rs
  • Etemenanki/protocols/src/vless/config.rs
  • Etemenanki/protocols/src/vless/core.rs
  • Etemenanki/protocols/src/vless/codec.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/mux/mod.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/protocols/src/mux/frame.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/concepts/src/core.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/protocols/tests/unit/vless/protocol.rs
  • Etemenanki/protocols/tests/unit/vless/validator.rs
  • Etemenanki/protocols/tests/unit/vless/core.rs
  • Etemenanki/protocols/tests/unit/vless/codec.rs
  • Etemenanki/protocols/tests/unit/mux/demux.rs
  • Etemenanki/protocols/tests/pipeline/vless.rs
  • Etemenanki/app/tests/integration/e2e_xray.rs
  • Etemenanki/app/tests/integration/e2e_xray_mux.rs
  • Etemenanki/app/tests/integration/e2e_sniff.rs
  • katana/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 则直接跳过。

CMD_UDP 请求之后,双向都传输带长度前缀的数据包,不含逐包地址:

字段 长度 含义
length 2 payload 长度,0 到 65535。
payload length 一个数据报。

所有上行数据包都发往请求头中的目标;所有下行数据包都被客户端视为来自该目标。要在一条连接上访问多个 UDP 对端,需要使用 mux 承载连接(XUDP,见 mux.cool 与 XUDP)。

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 开始。

protocols/src/vless/protocol.rs
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 的异步参考实现。管线中没有任何地方调用它们;单元测试把它们用作第二个独立的解码器:

protocols/src/vless/protocol.rs
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>;
protocols/src/vless/validator.rs
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;
}
protocols/src/vless/config.rs
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,由所有连接的协议核心只读共享,因此查找不需要加锁。

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 的流量计费(读取 UserTag payload)也会记到配置中的那个用户上。
  • 两侧都必须调用 process_uuid。如果用原始 UUID 作为 map 的键,或者用请求的原始字节查找,第 6、7 字节不同的客户端就会悄无声息地认证失败。
protocols/src/vless/core.rs
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];
}

私有的状态枚举就是整个状态机:

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

protocols/src/vless/codec.rs
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:

concepts/src/core.rs (the methods the codecs implement)
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。

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。

命令 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 标志应用到它自己的子流上。

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: …),关联保持不变。

对于 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,并原地 stage demux.out(),不将其取走。由于下一次调用会先清空 out,下一个上行不会再次 stage 这些下行帧:每一帧只发给客户端一次。
  • Event::TransportEof 调用 demux.on_transport_eof,然后推入 ShutdownTransport 和 Finish。

承载连接自身的流把用户和来源地址带入每个子流。帧格式、MAX_SESSIONS(每个承载连接 256 个子流)和 XUDP 在 mux 页面中介绍。

事件 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 = ())。

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。
  • 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)端到端覆盖
情况 结果
版本、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)。