Skip to content

VLESS

Source files: 29 · checked against 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 is the thinnest protocol in etemenanki-protocols: a request header that names a user, a command and a target, a two-byte response, and then the flow’s bytes verbatim. It has no cryptography of its own, so the transport underneath (TLS, WebSocket over TLS, gRPC over TLS) provides confidentiality. The module lives in protocols/src/vless/ and has three parts: the wire primitives, the server as a sans-I/O core (VlessCore), and the client as two sans-I/O codecs (VlessStream, VlessDatagram).

This page is for contributors who change that module. It gives the exact wire layout, walks through the VlessCore state machine event by event, explains why the user table is keyed by a processed UUID, and lists the tests that pin each behaviour. The generic machinery the core plugs into (ProxyCoreDecode, effects, the per-connection runtime) is described in the concepts pages linked at the end.

The module does It leaves to others
Parse and validate the request header from a growing byte slice: version, addons, command, port-first address. Reading from the socket, buffering, timers and dialing: the per-connection runtime in concepts/src/runtime.rs.
Authenticate the 16-byte user id against a Validator<T> and attach the configured UUID and payload to the flow. Deciding where the flow goes: the application’s connector and router.
Relay a TCP flow verbatim, answering once the outbound is connected. Confidentiality and server authentication: the TLS, WebSocket and gRPC transports.
Relay a UDP association to the single target named in the header, with 2-byte length framing both ways. mux.cool framing itself: Demux in protocols/src/mux/demux.rs, shared with Trojan and VMess.
Hand a mux carrier (command 0x03) to Demux. Sniffing rules: the collector in protocols/src/sniff/.
Encode the request and decode the response on the client side. Config parsing: etemenanki-app and katana build the Validator and the codecs.

XTLS flow (Vision) and the reverse command are out of scope. A request with any addons is refused (see Validation order), and etemenanki-app rejects a flow key in its config with unknown field `flow`, expected `id` , because IdUser in app/src/config.rs is deny_unknown_fields. katana refuses a node whose panel sets a VLESS flow in build_protocol (src/inbound.rs) with the error node requests kernel-unsupported feature: VLESS XTLS flow.

File Contents
protocols/src/vless/mod.rs Re-exports VlessCore, VlessStream, VlessDatagram, VlessServerConfig, Command, RequestHeader, Validator.
protocols/src/vless/protocol.rs Constants, Command, RequestHeader, slice parsers (parse_request_header, parse_response_header, parse_length_packet), encoders, and async reference readers and writers.
protocols/src/vless/validator.rs Validator<T>: the user table keyed by processed UUID.
protocols/src/vless/config.rs VlessServerConfig<T>: the user list, and validator() to build the table.
protocols/src/vless/core.rs VlessCore<T>: the server state machine, an implementation of ProxyCoreDecode.
protocols/src/vless/codec.rs VlessStream (ProxyCoreEncode) and VlessDatagram (ProxyCoreEncodeDatagram).

All multi-byte integers are big-endian. The layout follows Xray’s proxy/vless/encoding.

Offset Field Size Meaning
0 version 1 Must be 0x00 (VERSION).
1 uuid 16 The user id, raw bytes as on the wire.
17 addons length 1 Length of the protobuf addons that follow. Must be 0x00: a non-zero value means an XTLS flow and is refused.
18 command 1 0x01 TCP (CMD_TCP), 0x02 UDP (CMD_UDP), 0x03 mux (CMD_MUX). Anything else is refused.
19 address 5 to 259 Port first, then the typed address (next table): 7 bytes for IPv4, 19 for IPv6, 4 + n for an n-byte domain. Absent for CMD_MUX: the header ends at offset 19.

The longest header is REQUEST_HEADER_MAX = 1 + 16 + 1 + 1 + AddressCodec::MAX_LEN = 278 bytes, where AddressCodec::MAX_LEN = 1 + 1 + 255 + 2 = 259.

VLESS uses the VMess-style codec, ADDR = AddressCodec::VMESS (protocols/src/helpers/address.rs): the port comes first, and the type bytes differ from SOCKS.

Field Size Meaning
port 2 Destination port.
type 1 0x01 IPv4, 0x02 domain, 0x03 IPv6. Any other value fails with unknown address type: N.
address 4, 1 + n, or 16 IPv4 octets; a length byte and n domain bytes; or IPv6 octets.

A domain is decoded by parse_domain_bytes. A domain that starts with a digit or [ and parses as an IP literal (brackets stripped) is folded back into Remote::IpAddr. Anything else must be non-empty UTF-8 made only of ASCII letters, digits, -, . and _; otherwise the parse fails with empty domain name, non-utf8 domain or invalid domain name: … (all InvalidData). The folding matters for sniffing, which only runs for IP destinations.

Offset Field Size Meaning
0 version 1 0x00.
1 addons length 1 Length of addons that follow.
2 addons n Skipped by the client.

The server always sends RESPONSE_HEADER = [VERSION, 0]. The client parser, parse_response_header, accepts and skips non-empty response addons but refuses any version other than 0. Request addons are refused because they carry an XTLS flow; response addons are skipped.

After a CMD_UDP request, both directions carry length-prefixed packets with no per-packet address:

Field Size Meaning
length 2 Payload length, 0 to 65535.
payload length One datagram.

Every uplink packet goes to the target in the request header; every downlink packet is reported by the client as coming from that target. A mux carrier is the way to reach several UDP peers over one connection (XUDP, see mux.cool and XUDP).

A CMD_MUX request is 19 bytes: version, uuid, addons length and command, with no address. parse_request_header synthesises the destination with mux_destination() (protocols/src/mux/mod.rs): the domain MUX_ADDRESS = "v1.mux.cool", port 0, network TCP. It is never dialed; it is what a log line or a route rule sees for the carrier. The mux.cool frames start at offset 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 maps Tcp and Mux to DialNetwork::Tcp (a mux carrier is itself a stream; its sub-flows carry their own networks) and Udp to DialNetwork::Udp. RequestHeader::uuid is the id exactly as it appeared on the wire; the configured UUID comes from the validator.

The three parse_* functions are the forms the core and codecs use. They take the front of a buffer that may still be growing and return Ok(None) while it is too short, so the caller consumes nothing and waits for more bytes. Short input is detected through need_more (protocols/src/helpers/parse.rs), which turns an UnexpectedEof from the bounds-checked take/take_array helpers into None; every other error propagates.

The module also keeps async reference forms that read from and write to an AsyncRead/AsyncWrite directly. Nothing in the pipeline calls them; the unit tests use them as a second, independent decoder:

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 is the per-user payload that travels with every flow as NetworkUser::user_data. etemenanki-app uses (); katana uses its per-user UserTag (Validator<UserTag> in src/inbound.rs). Clone and Default are written by hand so they do not require T: Clone: the payload is always behind an Arc. The table is built once per inbound, wrapped in an Arc and shared read-only by every connection’s core, so a lookup takes no lock.

process_uuid zeroes bytes 6 and 7 of a UUID. Validator::add stores each user under the processed form of its configured UUID, and Validator::get processes the request id before the lookup:

fn process_uuid(mut id: [u8; 16]) -> [u8; 16] {
id[6] = 0;
id[7] = 0;
id
}

This is a port of ProcessUUID in Xray’s proxy/vless/validator.go, whose MemoryValidator keys users the same way. Doing the same keeps authentication identical to an Xray server: a client id that Xray matches to a user is matched here too. Byte 6 holds the UUID version nibble, so the key does not depend on the version bits. The consequences for contributors:

  • get returns the configured Uuid and its payload, not the request bytes. The core puts that Uuid into UserAuthorization::Uuid and the payload into NetworkUser::user_data, so routing and logging see the configured id, and katana’s accounting (which reads the UserTag payload) charges the configured user, even when the client’s bytes 6 and 7 differ.
  • Both sides must apply process_uuid. Keying the map by the raw UUID, or looking up raw request bytes, silently breaks clients whose bytes 6 and 7 differ.
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];
}

The private state enum is the whole state machine:

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,
}

The core composes the shared pieces from protocols/src/core/mod.rs rather than reimplementing them:

Piece Role in VlessCore
Timing The single deadline per phase: HANDSHAKE_TIMEOUT, SNIFF_TIMEOUT or RELAY_IDLE_TIMEOUT. touch runs at the top of every byte event (Transport, Outbound, Datagram). is_established() delegates to it and is true in Phase::Relay and Phase::Closing.
SniffPrefix The first payload bytes of a TCP flow with an IP destination, held until the flow opens.
Passthrough<FlowKey> Half-close bookkeeping for the one TCP outbound, keyed FlowKey::Direct.
Demux<T> The mux.cool demultiplexer for a CMD_MUX carrier; its sub-flows are keyed FlowKey::Sub(SubKey).

held() returns the demux’s held buffer in State::Mux and the sniff prefix otherwise, so Effect::ForwardHeld ranges resolve against whichever buffer is live.

Constant Value Why
VlessCore::BUF_SIZE 16 KiB The runtime’s read and staging buffers. A UDP frame carries up to MAX_DATAGRAM bytes behind its 2-byte length, and a mux downlink read of up to BUF_SIZE needs room for its Keep headers.
STAGING_RESERVE 272 + downlink_overhead(16384) = 808 What one event may stage beyond echoing its own payload: the response header, a UDP frame’s length prefix, or the mux Keep headers one outbound read splits into.
downlink_overhead(read_size) read_size.div_ceil(MAX_DATA_LEN) * FRAME_OVERHEAD_MAX = 2 × 268 One Keep header per MAX_DATA_LEN (8 KiB) piece; FRAME_OVERHEAD_MAX = 2 + 4 + 1 + AddressCodec::MAX_LEN + 2 = 268 (protocols/src/mux/frame.rs).
MAX_DATAGRAM 8192 Raised from the trait default of 4096. The runtime polls a datagram outbound only when staging has STAGING_RESERVE + MAX_DATAGRAM free, which fits in 16 KiB, and reads each packet into at most this many bytes; like a kernel recv, a longer downlink packet is truncated.

The runtime’s contract (concepts/src/core.rs → ProxyCoreDecode::STAGING_RESERVE) is that every event except Deadline arrives only with that much staging room free, plus the payload length for outbound byte events. That is why the core can treat a failed fx.stage or staging.reserve as a bug and return 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;
}

Both implement ProxyCoreEncodeHandshake with type Target = Destination (the upstream VLESS server the runtime dials), type Error = io::Error and const STAGING_RESERVE: usize = RESERVE, which is 278. VlessStream adds ProxyCoreEncode; VlessDatagram adds 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 picks the codec per flow in app/src/outbound/mod.rs: a flow whose destination.network is DialNetwork::Udp gets VlessDatagram::new(&uuid, &flow.destination), every other flow gets VlessStream::new(&uuid, &flow.destination). Both run inside ProxyClient<VLESS_BUF, VlessStream, VlessDatagram> with VLESS_BUF = 16 * 1024. Each flow opens its own upstream connection; the client side never sends CMD_MUX.

stateDiagram-v2
  [*] --> Handshake
  Handshake --> Tcp: CMD_TCP, sniff off or domain target
  Handshake --> Sniff: CMD_TCP, sniff on and IP target
  Handshake --> Udp: CMD_UDP, reply staged
  Handshake --> Mux: CMD_MUX, reply staged
  Handshake --> Done: TransportEof
  Sniff --> Tcp: verdict, deadline or TransportEof
  Tcp --> Tcp: Connected stages the reply once
  Udp --> Done: TransportEof or outbound gone
  Mux --> Done: TransportEof
  Done --> [*]

Tcp itself ends through Passthrough: the connection finishes once both halves have closed, when the outbound is gone, or when Timing reports the relay idle. A validation error in Handshake does not move to Done; handle returns Err and the runtime tears the connection down.

parse_request_header checks each field as soon as its byte is present, so malformed input fails before the whole header has arrived, and the user lookup runs only on a complete, well-formed header.

flowchart TB
  v{"byte 0 == 0?"} -- no --> e1["InvalidData: invalid vless request version: N"]
  v -- yes --> u["bytes 1..17: uuid"]
  u --> a{"byte 17 == 0?"}
  a -- no --> e2["Unsupported: vless addons (xtls flow) are not supported"]
  a -- yes --> c{"byte 18 in 1, 2, 3?"}
  c -- no --> e3["InvalidData: invalid vless command: N"]
  c -- "3 (mux)" --> m["mux_destination(), 19 bytes used"]
  c -- "1 or 2" --> ad["port-first address from byte 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)"]

Any of these errors is returned from handle. The core has staged nothing at that point, so the client gets no response bytes before the connection closes. While the header is incomplete, on_transport returns Ok(0) and the runtime keeps the bytes in its read buffer; the first such event arms HANDSHAKE_TIMEOUT through Timing::touch.

Command Response header staged Why
CMD_TCP On the first Event::Connected, guarded by replied. A failed dial closes the connection without ever answering, so the client sees the stream end before any response header.
CMD_UDP Right after authentication, in the same handle call. The outbound socket opens lazily on the first packet; there is no connect to wait for.
CMD_MUX Right after authentication, before any sub-flow exists. Each sub-flow has its own outbound and its own success or failure, reported in mux frames.

A VLESS client does not wait for the response before sending payload (the client codec’s start returns Handshake::Done), so the first payload bytes usually arrive together with, or right after, the header. This is what makes sniffing possible before the dial.

sequenceDiagram
  participant C as VlessStream (client runtime)
  participant S as VlessCore (server runtime)
  participant O as Outbound
  C->>S: request header + first payload
  Note over S: parse, Validator::get, Flow::new
  S->>O: Effect::Open (FlowKey::Direct)
  S->>O: Effect::Forward payload (waits for the connect)
  O-->>S: Event::Connected
  S-->>C: stage RESPONSE_HEADER [0, 0]
  O-->>S: Event::Outbound bytes
  S-->>C: stage verbatim
  Note over C: open() consumes 2 bytes as an empty frame, then plaintext
  C->>S: TransportEof
  S->>O: Effect::Shutdown (half-close)
  O-->>S: Event::OutboundEof
  S-->>C: Effect::ShutdownTransport, then Effect::Finish

After the header, on_transport in State::Tcp forwards the whole slice with Passthrough::on_transport (Effect::Forward over 0..data.len()), and Event::Outbound stages the whole slice with Passthrough::on_outbound. Both clear the sniff prefix first: by then the ForwardHeld that referenced it has been applied, which the runtime’s pin rule guarantees (no byte event is delivered while a held-range effect is pending).

Event::ConnectFailed and Event::OutboundError call Passthrough::on_outbound_gone, which pushes ShutdownTransport and Finish. Staged bytes still drain.

With sniff on and an IP destination (worth_sniffing in protocols/src/sniff/mod.rs), a TCP request enters State::Sniff(flow) and Phase::Sniff, which arms SNIFF_TIMEOUT (300 ms). Each transport event pushes bytes into SniffPrefix up to SNIFF_LIMIT (4 KiB) and consumes exactly what was taken. The flow opens (open_sniffed) when:

  • the collector returns a verdict other than Verdict::More: Found (a sniffer recognised the bytes) or Exhausted (4 KiB collected without a match). A payload that no sniffer recognises keeps the flow waiting until one of these or the deadline;
  • the deadline fires (Expired::Sniff);
  • the client half-closes (TransportEof), in which case the half-close is applied right after the open.

open_sniffed copies SniffPrefix::result() into flow.sniffed, pushes Effect::Open, then Effect::ForwardHeld over 0..held if anything was collected, and enters Phase::Relay. A domain destination skips sniffing and opens at once. UDP associations and mux carriers are never sniffed by the VLESS core; Demux applies the sniff flag to its own sub-flows.

sequenceDiagram
  participant C as VlessDatagram (client runtime)
  participant S as VlessCore
  participant O as UDP outbound
  C->>S: header (CMD_UDP, target T)
  S-->>C: stage RESPONSE_HEADER
  C->>S: len + packet 1, len + packet 2
  S->>O: Effect::Open (first complete frame only)
  S->>O: Effect::SendTo T (packet 1)
  S->>O: Effect::SendTo T (packet 2)
  O-->>S: Event::Datagram (its from is not encoded)
  S-->>C: stage len + payload
  Note over C: open_from() labels every reply as coming from T
  C->>S: TransportEof
  S->>O: Effect::Close, ShutdownTransport, Finish

In State::Udp, on_transport loops over complete frames with parse_length_packet. For each one it pushes Effect::SendTo { key: FlowKey::Direct, to: flow.destination.clone(), range }, with the range pointing at the payload inside the event slice, so packets are forwarded without copying. The to is always the header target. Effect::Open is pushed lazily before the first SendTo, and opened records it. A trailing partial frame is not consumed and waits in the read buffer.

In the other direction, Event::Datagram reserves len + 2 bytes in staging, writes the big-endian length and copies the payload. The packet’s from is not encoded; the wire has no field for it. A payload longer than u16::MAX would fail with vless: packet exceeds a u16, but the runtime never delivers one: it reads each packet into at most MAX_DATAGRAM bytes.

finish_udp (on TransportEof) pushes Effect::Close only when the outbound was opened, then ShutdownTransport and Finish. ConnectFailed and OutboundError end the association the same way, without Close. Event::SendFailed drops that one packet with a debug log line (vless: packet to … dropped: …) and keeps the association.

For CMD_MUX, the core stages the response, moves to State::Mux(Demux::new(flow, self.sniff)) and enters Phase::Relay. From then on:

  • Event::Transport calls demux.feed(data, 0, fx). VLESS is an unencrypted carrier, so Demux leaves a trailing partial frame in the runtime’s read buffer rather than copying it. The core then stages take_out() when it is not empty. Every Demux call clears out before it writes, so after feed it holds only the End frames with which the uplink declined sessions: a New beyond MAX_SESSIONS or for an id already in use, or Keep data for a session the demux does not know.
  • Event::Outbound, Event::Datagram, Event::OutboundEof, Event::ConnectFailed and Event::OutboundError for a FlowKey::Sub key go to on_sub, which calls Demux::on_outbound, on_datagram or on_outbound_gone and stages demux.out() in place, without taking it. Because the next call clears out first, the next uplink does not stage those downlink frames again: each goes to the client once.
  • Event::TransportEof calls demux.on_transport_eof, then pushes ShutdownTransport and Finish.

The carrier’s own flow carries the user and source into every sub-flow. The frame format, MAX_SESSIONS (256 sub-flows per carrier) and XUDP are described on the mux page.

Event Handshake Sniff Tcp Udp Mux
Transport Parse; Ok(0) while short Collect prefix Forward verbatim Frames to SendTo Demux::feed
Outbound Ignored Ignored Stage verbatim Ignored on_sub (sub keys)
Datagram Ignored Ignored Ignored Stage length-prefixed on_sub (sub keys)
Connected – – Stage reply once – –
ConnectFailed, OutboundError – – on_outbound_gone ShutdownTransport, Finish on_sub gone
OutboundEof – – on_outbound_eof – on_sub gone
TransportEof Finish Open, then half-close on_transport_eof finish_udp Demux EOF, Finish
Deadline Err(TimedOut) Open with what was held Finish (idle) Finish (idle) Finish (idle)
SendFailed Logged, packet dropped same same same same

TransportDatagram and TransportSendFailed return Ok(0): VLESS runs over byte-stream transports only (type TransportAddr = ()).

sequenceDiagram
  participant P as plaintext side
  participant R as ProxyClientRuntime
  participant U as upstream server
  R->>U: dial
  R->>R: start(): stage request header, Handshake::Done
  P->>R: poll_write(plain)
  R->>U: header + seal(plain) verbatim
  U-->>R: 0x00 0x00 + data
  R->>R: open(): Frame consumed 2, plain 0..0
  R->>R: open(): Frame over the whole slice
  R-->>P: poll_read(data)
  • new encodes the header once with encode_request_header(uuid, Command::Tcp, dest); start puts it into staging, clears it and returns Handshake::Done, so plaintext can follow in the same write.
  • reply is never called for a codec whose start returns Done; it returns the error vless: the response header is read with the first frame if it is.
  • open parses the response header first. Until it is complete it returns Opened::NeedMore; then it returns an empty frame (plain: 0..0) that consumes the header and any response addons. After that, every non-empty slice is one frame of plaintext.
  • seal copies the plaintext verbatim; finish stages nothing, because the wire’s own EOF carries the half-close.
  • A failed Staging::put returns vless: staging room below the declared reserve.
  • new encodes a Command::Udp header for target and keeps a clone of target.
  • seal_to ignores its to argument: the association is bound to the header target, and the wire has no per-packet address. It returns Ok(None), staging nothing, when the payload is longer than u16::MAX or does not fit in the room offered. The client runtime turns None into InvalidInput (codec refused a packet that fits its reserve). A packet larger than the client’s buffer fails earlier in ProxyClientRuntime::make_room with frame larger than the client runtime's buffer.
  • open_from consumes the response header as an empty frame with no source (None), which the runtime skips. Every later complete length-prefixed frame is returned with Some(self.target.clone()) as its source.
Invariant Mechanism Pinned by
The version byte must be 0, in requests and responses. parse_request_header and parse_response_header check byte 0 before anything else. 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)
A request with addons is refused. addons_len != 0 returns ErrorKind::Unsupported. rejects_nonempty_addons, request_header_is_parsed_from_a_slice_once_whole
Only commands 1, 2, 3 are accepted. The match cmd in parse_request_header returns InvalidData for any other byte. Not pinned by a dedicated VLESS test.
A mux request has no address, and the bytes after byte 19 are left for Demux. Command::Mux returns mux_destination() with 19 bytes consumed. mux_command_synthesises_its_destination, mux_request_header_roundtrips, request_header_is_parsed_from_a_slice_once_whole
An incomplete header consumes nothing. need_more maps short input to Ok(None); the core returns Ok(0). request_header_is_parsed_from_a_slice_once_whole (cuts at 0, 1, 17, 18, 19, 22 and one byte short); tcp_request_opens_and_replies_only_once_connected (first 10 bytes)
Users are matched on the processed UUID and reported by their configured UUID. process_uuid on both add and get; get returns the stored Uuid. validator_matches_ignoring_bytes_6_and_7 (validator.rs)
An unknown user gets no response. Validator::get returns None, and the core returns PermissionDenied before staging anything. unknown_uuid_is_refused (core.rs)
The TCP response goes out once, and only after the outbound connects. Event::Connected stages RESPONSE_HEADER under the replied flag. tcp_request_opens_and_replies_only_once_connected
A failed connect closes without a response. ConnectFailed in State::Tcp calls Passthrough::on_outbound_gone, and nothing was staged before. a_refused_connect_closes_without_a_reply
A sniffed flow opens with the recovered domain and forwards the held prefix first. open_sniffed sets flow.sniffed, then pushes Open, ForwardHeld, and the relay deadline. sniffing_holds_the_prefix_then_opens_with_the_domain
A UDP association is answered at once, opens its outbound on the first packet, sends only to the header target, and leaves a partial frame unconsumed. State::Udp with the opened flag; SendTo always uses flow.destination. udp_association_replies_at_once_and_frames_both_ways
A mux carrier is answered at once and its sub-flows are demultiplexed. Command::Mux stages the reply and hands off to Demux. vless_answers_mux_at_once_and_demultiplexes (protocols/tests/unit/mux/demux.rs)
Each mux downlink frame is staged once. Every Demux call that writes out clears it first, so after feed it holds only that uplink’s declines, never the frames on_sub staged earlier. a_downlink_frame_is_not_sent_again_by_the_next_uplink, vless_answers_mux_at_once_and_demultiplexes (protocols/tests/unit/mux/demux.rs)
The client datagram codec frames every packet to the fixed target and reports every reply as coming from it. seal_to ignores to; open_from returns self.target. datagram_codec_frames_to_the_fixed_target (codec.rs)
The core never stages more than its reserve beyond an event’s payload. STAGING_RESERVE covers the reply, a length prefix, or downlink_overhead(BUF_SIZE) of mux headers; a shortfall is staging_full(). Exercised end to end by new_server_vs_new_client_tcp and new_server_vs_new_client_udp (protocols/tests/pipeline/vless.rs)
Situation What happens
Bad version, addons, command or address handle returns Err (InvalidData or Unsupported); the runtime ends with RuntimeError::Core, and the app logs vless connection from … ended: … at debug. No response is sent.
Unknown user PermissionDenied (invalid vless request user id), same path.
Client stops mid-header The first event armed HANDSHAKE_TIMEOUT (10 s); Deadline returns client did not complete its request in time (TimedOut). A client that never sends a byte produces no event: etemenanki-app’s drive in app/src/serve.rs watches that case with the same timeout until is_established() turns true.
Client closes before or during the header TransportEof in Handshake finishes without error.
TCP dial fails on_outbound_gone: ShutdownTransport, Finish; no response.
UDP outbound fails ShutdownTransport, Finish, without Close.
One UDP send fails The packet is dropped with a debug line; the association stays up.
Idle relay After RELAY_IDLE_TIMEOUT (300 s) with no byte event, Timing::expired pushes Finish.
Uplink frame larger than the read buffer The runtime reports RuntimeError::FrameTooLarge (protocol frame exceeds the read buffer) and the connection ends. With BUF_SIZE = 16 KiB, an uplink UDP frame on a plain VLESS association can carry at most 16 382 payload bytes, although its length field allows 65 535.
Cancellation Dropping the runtime future closes the transport and every outbound with it; the core owns no tasks, channels or locks, so nothing outlives the connection.
Name Value Where
VlessCore::BUF_SIZE 16 KiB protocols/src/vless/core.rs
STAGING_RESERVE (server) 808 bytes protocols/src/vless/core.rs
MAX_DATAGRAM 8192 bytes protocols/src/vless/core.rs
REQUEST_HEADER_MAX, client STAGING_RESERVE 278 bytes protocols/src/vless/protocol.rs, codec.rs
UDP length prefix u16, at most 65535 bytes on the wire; 16 382 uplink and MAX_DATAGRAM downlink in practice 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 sub-flows per carrier) 256 protocols/src/mux/demux.rs
VLESS_BUF (app client runtime buffer) 16 KiB app/src/outbound/mod.rs

The unit tests are compiled into the library through #[path] attributes and run with cargo test -p etemenanki-protocols --lib vless. Core tests drive VlessCore through CoreHarness (protocols/src/core/harness.rs), which records effects and staged bytes without any I/O.

Test File What it pins
request_header_roundtrip_tcp_ipv4, request_header_roundtrip_udp_domain, request_header_roundtrip_ipv6 protocols/tests/unit/vless/protocol.rs The byte layout (offsets 0, 1..17, 17, 18) and a round trip for each address type.
rejects_bad_version, rejects_nonempty_addons same Version and addons refusal, with their error kinds.
mux_command_synthesises_its_destination, mux_request_header_roundtrips same A 19-byte mux header that leaves the next frame untouched.
response_header_roundtrip, response_header_skips_addons, response_header_rejects_version same The response header and addon skipping.
length_packet_roundtrip, multiple_length_packets same UDP framing.
request_header_is_parsed_from_a_slice_once_whole, response_header_and_length_packets_are_parsed_from_slices same The slice parsers return None at every cut point, report the bytes consumed, stop a mux header at 19 bytes, and refuse addons and a wrong response version.
validator_matches_ignoring_bytes_6_and_7 protocols/tests/unit/vless/validator.rs Processed-UUID lookup that returns the configured 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 handshake, deadline arming, reply timing and the failure paths.
sniffing_holds_the_prefix_then_opens_with_the_domain same Sniff phase, held prefix and deadlines.
udp_association_replies_at_once_and_frames_both_ways same UDP reply timing, lazy open, target pinning, partial frames and teardown.
stream_codec_consumes_the_response_header_as_an_empty_frame, datagram_codec_frames_to_the_fixed_target protocols/tests/unit/vless/codec.rs Both client codecs.
vless_answers_mux_at_once_and_demultiplexes protocols/tests/unit/mux/demux.rs The mux carrier through VlessCore, and that the next uplink sends a packet to another peer without staging the earlier reply again.
a_downlink_frame_is_not_sent_again_by_the_next_uplink same A reply read from out() in place, as VlessCore stages it, is not queued again by the next feed.
new_server_vs_new_client_tcp, new_server_vs_new_client_udp protocols/tests/pipeline/vless.rs The real runtimes over loopback: a 70 000-byte echo with half-close, and UDP packets of 8 and 1500 bytes. Run with cargo test -p etemenanki-protocols --test pipeline vless.
app_client_* and app_server_* over WebSocket, gRPC and TLS app/tests/integration/e2e_xray.rs Interop with a real Xray binary in both directions, with VLESS as the tunnel protocol.
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 An Xray client with mux enabled against the VLESS server.
an_http_host_routes_an_ip_addressed_flow and the other sniffing tests app/tests/integration/e2e_sniff.rs Sniffing through a VLESS inbound, end to end.

The Xray interop tests build Xray with go build from the reference tree in the workspace and skip themselves on a machine without Go (printing SKIP: `go` not available; skipping xray interop test) or when that build fails (SKIP: failed to build xray-core).