Skip to content

VMess: wire format and core

Source files: 26 · checked against 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

This page describes everything VMess puts on the wire after the authentication ID: the sealed request header and its plaintext layout, the response header, the option bits, the body chunk stream with its SHAKE128 length mask and padding, and the two sides that speak it: the VMessCore server state machine and the VMessStream and VMessDatagram client codecs.

Read it before you change anything under protocols/src/vmess/ other than the key derivations and the account table. Those, together with the authentication ID, the header AEAD envelope and the replay filter, are on VMess: keys and authentication. The configuration side is in the VMess user guide.

VMess in Etemenanki is the modern AEAD form only: the header is sealed with AES-128-GCM under keys derived from the user’s UUID, and the body is a chunk stream sealed with AES-128-GCM or ChaCha20-Poly1305. The module splits the work by what each file owns:

File Owns Leaves to
protocols/src/vmess/protocol.rs The bytes of the request and response headers, Command, Security, RequestOptions, the FNV-1a checksum aead.rs for the envelope around the request header
protocols/src/vmess/framing.rs One direction of the body: chunk sealing and opening, the length mask, padding, the per-chunk nonce session.rs for which key and IV a direction uses
protocols/src/vmess/session.rs Per-connection derived state: OutboundSession for a client, chunk_streams and response_header for a server keys.rs for the request-to-response derivation
protocols/src/vmess/core.rs VMessCore, the sans-I/O server the runtime for I/O, the mux module for sub-flows
protocols/src/vmess/codec.rs VMessStream and VMessDatagram, the sans-I/O client the client runtime for I/O
protocols/src/vmess/mod.rs The public surface: every submodule, plus re-exports of VMessCore, VMessStream, VMessDatagram, Security, RequestOptions, Account, AccountValidator, the key newtypes and Uuid

Nothing here does I/O except two async helpers kept for tests and reference (decode_response_header and ChunkStream::read_chunk). The server core and the client codecs work on slices the runtime hands them, decrypt in place and report ranges. The general contract they implement is on Server core and Client runtime.

One VMess connection carries one request header, then a chunk stream in each direction. Each stream ends with an empty terminator chunk.

sequenceDiagram
  participant C as Client codec
  participant S as VMessCore
  participant O as Outbound
  C->>S: auth id (16 bytes)
  C->>S: sealed request header
  C->>S: body chunks
  Note over S: TCP: Effect::Open, then Forward per chunk
  S->>O: dial target
  O-->>S: Event::Connected
  S->>C: sealed response header (38 bytes)
  O-->>S: Event::Outbound bytes
  S->>C: body chunks
  C->>S: terminator chunk
  Note over S: Effect::Shutdown on the outbound
  O-->>S: Event::OutboundEof
  S->>C: terminator chunk

The client does not wait for the response header before it sends body chunks: its handshake is Handshake::Done right after start, and the response header is opened as an empty first frame on the downlink.

The first flight from a client has this shape:

Part Size Produced by
Auth id 16 aead::create_auth_id
Sealed length 2 + 16 aead::seal_vmess_aead_header
Connection nonce 8 aead::seal_vmess_aead_header
Sealed request header N + 16 aead::seal_vmess_aead_header over the N-byte plaintext that encode_request_header builds
Body chunks variable ChunkStream::seal_chunk_into

The first four parts are described on VMess: keys and authentication. This page starts at the N plaintext bytes inside the sealed request header.

encode_request_header builds the plaintext and parse_request_header reads it. All multi-byte integers are big-endian.

Offset Field Size Meaning
0 Version 1 VERSION, always 1
1 Body IV 16 BodyIv of the request direction
17 Body key 16 BodyKey of the request direction
33 Response header 1 A random byte the server must echo as the first byte of its response header
34 Options 1 RequestOptions bits, see Options
35 Padding length and security 1 High nibble: padding length P (0 to 15). Low nibble: Security byte
36 Reserved 1 Written as 0x00; the parser does not read it
37 Command 1 0x01 TCP, 0x02 UDP, 0x03 mux
38 Address variable Port first, then the address; absent for a mux request
38 + A Padding P Random bytes
38 + A + P Checksum 4 FNV-1a 32 of every byte before it

The fixed part is 38 bytes, so the shortest legal header (a mux request with no padding) is 42 bytes.

The address uses AddressCodec::VMESS from protocols/src/helpers/address.rs, which writes the port before the address:

Field Size Meaning
Port 2 Destination port
Type 1 0x01 IPv4, 0x02 domain, 0x03 IPv6
Address 4, 1 + L, or 16 IPv4 bytes, a length byte plus up to 255 domain bytes, or IPv6 bytes

The longest encoded address is AddressCodec::MAX_LEN, 259 bytes.

A mux request carries no address at all. The header ends at the command byte, and the parser substitutes crate::mux::mux_destination(), which is v1.mux.cool port 0 over TCP, the same destination Xray synthesises. Command::network maps Mux to DialNetwork::Tcp, because the carrier itself is a stream; each sub-flow carries its own network.

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 appends the checksum and hands the result straight to aead::seal_vmess_aead_header, so its output is the whole sealed header, auth id included. parse_request_header takes the plaintext that aead::open_vmess_aead_header_slice returned.

parse_request_header checks the plaintext in this order and fails on the first problem. Every failure ends the connection. The kind is io::ErrorKind::InvalidData, except for an address that runs past the end of the plaintext, which surfaces as io::ErrorKind::UnexpectedEof.

  1. Floor. Fewer than 42 bytes (the 38 fixed bytes plus the checksum) fails with vmess: request header too short.

  2. Checksum. The last four bytes must equal fnv1a32 of everything before them, or the parser fails with vmess: request header checksum mismatch. The checksum is checked before any field is read.

  3. Version. Byte 0 must be VERSION; anything else fails with vmess: unsupported version <n>.

  4. Options. RequestOptions::from_wire refuses global padding without chunk masking: vmess: global padding negotiated without chunk masking.

  5. Security. The low nibble of byte 35 must be 3 or 4. Security::from_byte fails any other value with vmess: unsupported security type <n>, which covers Xray’s none (5), zero (6) and the legacy values.

  6. Command. Byte 37 must be 0x01, 0x02 or 0x03; Command::from_byte fails any other value with vmess: unsupported command <n>.

  7. Address. For TCP and UDP, AddressCodec::VMESS.read_slice decodes the port and address from byte 38. It fails on an unknown type (unknown address type: <n>), on an empty, non-UTF-8 or otherwise invalid domain (empty domain name, non-utf8 domain, invalid domain name: <name>; a domain may contain only ASCII letters, digits, -, . and _), and on a field that runs past the plaintext. A domain that parses as an IP literal is returned as an IP address. A mux request skips this step.

  8. Exact length. 38 + address + P + 4 must equal the plaintext length exactly, computed with checked arithmetic. Any surplus or shortfall fails with vmess: request header length mismatch. This is what stops trailing bytes from hiding inside an authenticated header.

The server answers with four plaintext bytes, sealed in two AEAD pieces under keys derived from the response-direction body key and IV:

Part Size Content
Sealed length 2 + 16 The value 4, sealed with GcmKey::response_len and GcmNonce::response_len
Sealed payload 4 + 16 [response_header, 0x00, 0x00, 0x00], sealed with GcmKey::response_payload and GcmNonce::response_payload

The server always sends option 0 and no dynamic-port command, so the sealed response header is always 38 bytes.

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>>;

The client codecs use decode_response_header_slice. It returns Ok(None) while the buffer is too short for either part, Ok(Some(n)) with the bytes the header took, and an error when a tag fails or when the first payload byte is not the response_header byte the client chose: vmess: unexpected response header.

The low nibble of header byte 35 picks the body AEAD for both directions:

Variant Byte Body AEAD Chunk key
Security::Aes128Gcm 3 AES-128-GCM The 16-byte body key
Security::ChaCha20Poly1305 4 ChaCha20-Poly1305 md5(k) followed by md5(md5(k)), 32 bytes (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>;
}

Both ciphers have a 16-byte tag (aead::TAG_SIZE) and a 12-byte nonce, so the framing code does not branch on the cipher except in BodyCipher. The server accepts whichever of the two the client names; it has no cipher setting of its own. On the client side, etemenanki-app maps a missing security, auto and aes-128-gcm to Aes128Gcm, and chacha20-poly1305 to ChaCha20Poly1305, ignoring case; any other value fails the outbound build with unknown vmess security "<value>" (app/src/outbound/mod.rs → parse_security).

RequestOptions wraps the option byte. Three bits have a meaning; from_wire keeps any other bits it receives and nothing reads them:

Constant Bit Accessor Effect in ChunkStream
OPT_CHUNK_STREAM 0x01 chunk_stream() new and modern always set it; from_wire does not require it. The body is always chunked; the framing code never consults the bit.
OPT_CHUNK_MASKING 0x04 chunk_masking() XOR each size field with two SHAKE128 bytes
OPT_GLOBAL_PADDING 0x08 global_padding() Append 0 to 63 random bytes to each chunk, the count drawn from 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;
}

Padding requires masking. Global padding draws its length from the same SHAKE128 keystream that masking uses. Xray takes the padding length from the masking size parser and refuses a request that asks for global padding without masking, so the combination has no interoperable meaning. The type rules it out at both entrances:

  • RequestOptions::new(false, true) fails with io::ErrorKind::InvalidInput, vmess: global padding requires chunk masking.
  • RequestOptions::from_wire fails the same bits with io::ErrorKind::InvalidData, vmess: global padding negotiated without chunk masking.
  • RequestOptions::modern(global_padding) always sets chunk stream and masking, so it cannot produce the bad shape. The client codecs use it through OutboundSession::new.

protocols/src/vmess/framing.rs turns plaintext into chunks and back, one ChunkStream per direction.

Field Size Meaning
Size 2 mask XOR (n + 16 + p), big-endian; mask is 0 without chunk masking
Ciphertext n The plaintext, sealed in place
Tag 16 The AEAD tag, detached and written after the ciphertext
Padding p Random bytes; p is 0 without global padding

The size field counts everything after it: ciphertext, tag and padding. A chunk whose size equals 16 + p carries no plaintext and is the terminator; ChunkHeader::is_terminator recognises it and both readers report the end of the stream.

The encoder and decoder of a direction are built from the same key, IV and options, and each advances its state exactly once per chunk in the same order. That order is the whole protocol:

flowchart TB
  A["next_mask()"] --> B{"global_padding?"}
  B -- yes --> C["p = shake.next_padding_len()"]
  B -- no --> D["p = 0"]
  C --> E{"chunk_masking?"}
  D --> E
  E -- yes --> F["mask = shake.next_u16()"]
  E -- no --> G["mask = 0"]
  F --> H["next_nonce(): count into bytes 0..2, count += 1"]
  G --> H
  H --> J["size = mask XOR (n + 16 + p)"]
  J --> I["seal plaintext in place, append tag"]
  I --> K["fill p random padding bytes"]
  • SHAKE128. ChunkStream::new seeds aead::Shake128 with the full 16-byte body IV of that direction. next_padding_len is next_u16() % 64, so padding is 0 to 63 bytes; next_u16 reads two keystream bytes big-endian. When both options are on, the padding length is drawn before the mask, as Xray’s AuthenticationWriter does.
  • Nonce. The 12-byte nonce starts as the first 12 bytes of the body IV. next_nonce overwrites bytes 0 and 1 with the chunk counter, big-endian, then increments the counter with wrapping_add. The counter is a u16, so the nonce is count || body_iv[2..12].
  • AEAD. Each chunk is sealed with an empty associated-data string, in place, with a detached 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>>>;
}

seal_chunk_into is the path the cores and codecs use. It checks out.room() against plaintext.len() + CHUNK_OVERHEAD_MAX before it touches the keystream or the counter, and returns None with the stream unchanged when the room is short. A caller can therefore retry with more room without desynchronising the direction. seal_chunk and seal_terminator allocate a BytesMut; outside framing.rs only the tests call them.

decode_header unmasks the size, advances the keystream, and rejects a size smaller than 16 + p with vmess: chunk size below overhead. open_body_in_place requires body.len() == header.size (vmess: chunk body length mismatch), advances the nonce, and opens the ciphertext against the tag (vmess: body chunk open failed).

The runtime delivers wire bytes in whatever pieces the transport produced, and a chunk can straddle two reads. Decoding its size field twice would advance the SHAKE keystream twice and desynchronise the direction for good. ChunkDecoder keeps the decoded header between calls:

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: fewer than 2 bytes / NeedMore
  NoHeader --> Pending: decode_header on a copy of bytes 0..2
  Pending --> Pending: body incomplete / NeedMore
  Pending --> NoHeader: whole chunk present / Data or End

open decodes the size field from a copy of the first two bytes and stores it in pending. While fewer than 2 + size bytes are present it returns NeedMore, and the caller presents the same bytes again with more appended. Once the chunk is complete it opens the body in place and returns Data with the plaintext range inside the slice, or End for the terminator, and clears pending. Both consumed values cover the size field, the body, the tag and the padding.

Each direction has its own key and IV. The client picks the request-direction values at random; both sides derive the response direction from them.

Direction Body key Body IV Built by
Request (client to server) BodyKey::random() BodyIv::random() Client: OutboundSession::request_encoder. Server: the first stream of chunk_streams
Response (server to client) BodyKey::response(), SHA-256(key)[..16] BodyIv::response(), SHA-256(iv)[..16] Server: the second stream of chunk_streams. Client: OutboundSession::response_decoder

Both directions use the security and options of the request header. Callers derive the response values through the response() methods on the BodyKey and BodyIv newtypes rather than by hashing raw bytes, so a response key cannot be built from an IV by accident; the details are on VMess: keys and authentication.

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 draws two random bytes: the first becomes the response_header byte and the low nibble of the second becomes the header padding length (0 to 15). It builds the options with RequestOptions::modern(global_padding), so chunk masking is always on for a client built here.

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];
}
  • The injected clock. now is the clock the auth id’s timestamp is checked against: the AuthId state calls self.validator.authenticate(&authid, (self.now)()). It is a plain fn() -> i64 rather than a closure, so the core carries no captured state for it and a test can pass a function that returns a fixed time. etemenanki-app (app/src/serve.rs) and katana both pass aead::now_unix. The clock is used only for that check; the client side stamps its auth id with now_unix directly inside aead::seal_vmess_aead_header.
  • T is the per-user data the account table carries. It reaches the outbound as NetworkUser { authorization: UserAuthorization::Uuid(uuid), user_data } inside the Flow, which is how katana attributes traffic to a panel user.
  • source is the client’s IP, copied into every Flow the core opens, including the sub-flows of a mux carrier.
  • sniff turns on destination sniffing for TCP requests to an IP address and is handed on to the mux demultiplexer.
stateDiagram-v2
  [*] --> AuthId
  AuthId --> Header: validator.authenticate matched
  Header --> Tcp: TCP, no sniffing
  Header --> Sniff: TCP to an IP, sniffing on
  Header --> Udp: UDP
  Header --> Mux: mux
  Sniff --> Tcp: verdict, timeout, terminator or EOF
  Udp --> Done: terminator, EOF or outbound gone
  Mux --> Done: terminator or EOF
  AuthId --> Done: TransportEof
  Header --> Done: TransportEof
  Tcp --> [*]: Passthrough finishes
  Done --> [*]

State<T> holds per-state data: Header keeps the AuthId, the matched CmdKey and the NetworkUser; Tcp keeps a Passthrough<FlowKey> and a replied flag; Udp keeps an opened flag; Mux owns a Demux<T>.

The core waits until 16 bytes are present, then asks the validator. A miss, whether an unknown user, a timestamp outside the window or a replayed id, fails with io::ErrorKind::PermissionDenied, vmess: unknown user or invalid auth id. A match moves to Header and consumes exactly 16 bytes.

aead::open_vmess_aead_header_slice returns Ok(None) until the whole envelope is present, and the core consumes nothing in the meantime. Once it opens, the core:

  1. parses the plaintext with parse_request_header;
  2. builds both chunk streams with chunk_streams, wrapping the request stream in a ChunkDecoder;
  3. seals the response header with response_header and keeps it in response until it is due;
  4. builds the Flow from the destination, the user and source;
  5. branches on the command, as below, and returns the envelope length.

The runtime then calls again on the remaining bytes, so body chunks that arrived in the same read are handled in the next state.

Command Next state Response header staged Outbound opened Phase armed
TCP Tcp On Event::Connected, or earlier if downlink bytes or the downlink terminator are sealed first At once: Effect::Open { key: FlowKey::Direct } Phase::Relay
TCP to an IP, sniffing on Sniff As TCP, once the flow opens When sniffing ends Phase::Sniff
UDP Udp At once With the first packet Phase::Relay
Mux Mux At once Per sub-flow, by the demultiplexer Phase::Relay

The response header is staged by reply, which writes response once and clears it. seal and terminate call reply first, so the header can never follow a body chunk. For TCP, waiting for Connected means a client whose target cannot be reached sees the connection close without a response header. A UDP association and a mux carrier have no single dial to wait for.

Every chunk opened from the transport becomes an Effect::Forward over its in-place plaintext range; empty ranges are skipped. Downlink bytes (Event::Outbound) are split into MAX_PAYLOAD pieces and sealed as chunks. The half-closes go through Passthrough:

Trigger What the core does
Uplink terminator chunk Sets uplink_done, then Passthrough::on_transport_eof: Effect::Shutdown on the outbound, plus Effect::Finish if the outbound has already ended
Event::TransportEof before a terminator Passthrough::on_transport_eof, the same half-close
Event::TransportEof after a terminator Effect::Finish if the outbound half is also closed; otherwise nothing
Event::OutboundEof terminate (response header if still due, then the downlink terminator; the encoder is dropped), then Passthrough::on_outbound_eof: Effect::ShutdownTransport, plus Effect::Finish if the transport has already ended
Event::ConnectFailed or Event::OutboundError Passthrough::on_outbound_gone: Effect::ShutdownTransport and Effect::Finish

For a TCP request whose destination is an IP (sniff::worth_sniffing) and with sniffing on, the core first collects plaintext in SniffPrefix. Each opened chunk is pushed into the prefix until the sniffer returns a verdict other than Verdict::More. Then open_sniffed stores the result in Flow::sniffed, emits Effect::Open and Effect::ForwardHeld for the collected bytes, and forwards the rest of that chunk and every later chunk in the same pass directly. The prefix is cleared at the next byte event, once the runtime has forwarded the held bytes.

Sniffing also ends, and the flow opens with what was collected, when the SNIFF_TIMEOUT deadline fires, when the uplink terminator arrives, or on TransportEof. The sniffers and their budget are on Sniffing.

Each uplink chunk is one packet to the header’s destination. The first packet emits Effect::Open { key: FlowKey::Direct }; every packet emits Effect::SendTo with the chunk’s plaintext range. Every downlink datagram (Event::Datagram) becomes exactly one chunk (seal with whole = true), because the chunk boundary is the packet boundary. The association ends through finish_udp on the uplink terminator or TransportEof: Effect::Close if the outbound was opened, the downlink terminator, Effect::ShutdownTransport and Effect::Finish. A failed or broken outbound ends it at once. A failed send (Event::SendFailed) is logged at debug level and dropped, as UDP would.

A mux request turns the chunk stream into a mux.cool carrier. The chunk boundaries and the mux frame boundaries are independent: one chunk can hold several frames, and a frame can straddle chunks. One byte event can open several chunks, and the core hands all of their plaintext ranges to the demultiplexer in one call:

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 calls it as demux.feed_chunks(data, &opened.plain, fx), once per byte event. feed_chunks consumes every chunk whole: complete frames are dispatched by their range in data, and a trailing partial frame is copied into the demultiplexer’s held buffer, completed by a later chunk of the same event or of the next one, and forwarded from the held buffer with Effect::ForwardHeld (Effect::SendToHeld for a UDP sub-flow). Several frames completed in one event each keep their own place in the held buffer, because the runtime applies the held forwards only after the event. The demultiplexer drops the completed frames from the held buffer, keeping only the partial tail, when the next byte event starts. That is why VMessCore::held returns Demux::held in the Mux state and the sniff prefix otherwise. Frames the demultiplexer queues toward the client, from sub-flow data or from its own End replies, are collected with take_out and sealed as ordinary chunks of at most MAX_PAYLOAD. Sub-flow events arrive with FlowKey::Sub(SubKey) and go to on_outbound, on_datagram or on_outbound_gone. The uplink terminator or TransportEof closes every sub-flow (Demux::on_transport_eof), sends the downlink terminator and finishes. The frame format, session generations and XUDP are on Mux and XUDP.

Timing is touched at the top of every byte-carrying event and moved by enter on each phase change:

Deadline Constant On expiry
Handshake HANDSHAKE_TIMEOUT, 10 s, armed by the first transport event handle returns handshake_timed_out(), io::ErrorKind::TimedOut
Sniffing SNIFF_TIMEOUT, 300 ms open_sniffed: the flow opens with whatever was collected
Relay idle RELAY_IDLE_TIMEOUT, 300 s, refreshed by every byte event Timing::expired pushes Effect::Finish

is_established is true from Phase::Relay on. The serve loops of etemenanki-app and katana poll it to end their own handshake watchdog, so a connection that is still sniffing counts as being in its handshake.

Constant Value Why
VMessCore::BUF_SIZE 32 KiB The runtime’s read and staging buffers. The app and katana instantiate the runtime with it.
STAGING_RESERVE 4096 Covers the 38-byte response header, the downlink terminator and the chunk overhead of one outbound read: a read of at most BUF_SIZE - STAGING_RESERVE bytes splits into at most 15 chunks of 82 bytes of overhead each.
MAX_DATAGRAM 8192 The largest UDP packet the runtime reads whole from a datagram outbound; a longer packet is truncated, as a kernel recv would. The runtime polls a datagram outbound only when STAGING_RESERVE + MAX_DATAGRAM bytes of staging are free, so a packet always fits as one chunk, and its size field stays well inside u16.
MAX_PAYLOAD 1966 Plaintext per stream chunk: 2048 - 16 - 2 - 64, so a framed chunk fits in 2 KiB even with maximum padding.
CHUNK_OVERHEAD_MAX 82 2 + 16 + 64: the most one sealed chunk adds to its plaintext.

seal and terminate turn a None from seal_chunk_into into staging_full() (staging room below the core's declared reserve, io::ErrorKind::Other), the error for a runtime that offered less room than it promised. A runtime that keeps its contract never triggers it.

protocols/src/vmess/codec.rs implements the client as two codecs over a shared Session: the sealed header, the request ChunkStream, a ChunkDecoder over the response stream, the ResponseSession and a replied flag.

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 stages the whole sealed header (auth id included) and returns Handshake::Done, so plaintext flows at once.
  • seal takes at most MAX_PAYLOAD bytes per call and seals them as one chunk. The runtime calls it again for the rest.
  • open first opens the response header with decode_response_header_slice and reports it as Opened::Frame with an empty plain range; after that it maps ChunkStep to Opened one to one.
  • finish stages the terminator chunk.
  • reply is never called, because start returns Done; it fails with vmess: the response header opens with the first frame.

Global padding on. Both codecs take global_padding as a constructor argument. etemenanki-app always passes true (app/src/outbound/mod.rs, the "vmess" arm), which matches what Xray clients send; interop with an Xray server is covered by app_client_vmess_ws_xray_server_early_data_plain. katana, the panel node agent, passes its outbound’s global_padding setting instead, which defaults to false.

Staging reserve. HEADER_MAX is 358 and STAGING_RESERVE rounds it up to 384. The sum does not include the 16-byte auth id; the true worst case, with a 255-byte domain and 15 bytes of header padding, is 374 bytes and still fits in the rounded reserve. If you change the header or the rounding, recompute the worst case including the auth id. A chunk needs at most CHUNK_OVERHEAD_MAX (82) beyond its plaintext, well under the reserve.

A codec that finds less room than it declared fails with vmess: staging room below the declared reserve (io::ErrorKind::Other); VMessDatagram::seal_to instead returns Ok(None) and stages nothing.

Invariant Enforced by Pinned by
A header is accepted only with version 1, a known security, a known command, a valid option combination and a matching FNV-1a checksum parse_request_header, Security::from_byte, Command::from_byte, RequestOptions::from_wire rejects_unknown_version, rejects_unknown_security, rejects_unknown_command, rejects_invalid_option_relationship in protocols/tests/unit/vmess/protocol.rs
The header plaintext has no byte beyond address, padding and checksum The exact-length check in parse_request_header request_header_roundtrips (valid shape)
Global padding is never negotiated without chunk masking RequestOptions::new, RequestOptions::from_wire, RequestOptions::modern rejects_invalid_option_relationship
Both ends of a direction advance SHAKE128 and the counter once per chunk, padding before mask ChunkStream::next_mask, ChunkStream::next_nonce body_chunks_roundtrip_gcm, body_chunks_roundtrip_chacha, body_chunks_roundtrip_masking_without_padding, body_chunks_roundtrip_plain_length in protocols/tests/unit/vmess/framing.rs (both ends agree); the Xray interop tests (the order matches Xray)
A chunk split across reads has its size field decoded once ChunkDecoder::pending a_chunk_split_across_reads_decodes_its_header_once in protocols/tests/unit/vmess/core.rs
A refused seal leaves the stream unchanged The room check at the top of seal_chunk_into The refusal itself: chunks_seal_into_staging_and_open_in_place (framing), datagram_codec_refuses_a_packet_that_does_not_fit in protocols/tests/unit/vmess/codec.rs (nothing staged)
The TCP response header goes out only after the dial succeeds, and always before the first downlink chunk State::Tcp { replied }, reply at the top of seal and terminate tcp_request_opens_and_replies_once_connected
UDP and mux answer the header at once reply in the Header state udp_replies_at_once_and_maps_chunks_to_packets; vmess_demultiplexes_across_chunk_boundaries in protocols/tests/unit/mux/demux.rs
One UDP packet is one chunk, in both directions seal(.., whole = true) in the core, seal_to in the codec udp_replies_at_once_and_maps_chunks_to_packets, new_server_vs_new_client_udp
The uplink terminator half-closes the outbound, not the whole connection Passthrough::on_transport_eof the_uplink_terminator_half_closes_the_outbound
The downlink terminator is sent once terminate takes the encoder out of Option tcp_request_opens_and_replies_once_connected (terminator after the last chunk)
A replayed auth id is refused AccountValidator::authenticate a_replayed_auth_id_is_refused
Mux frames may straddle chunk boundaries, and every frame one read completes is forwarded from the held buffer Demux::feed_chunks, VMessCore::held vmess_demultiplexes_across_chunk_boundaries, vmess_keeps_every_frame_one_read_completes, vmess_mux_payload_spans_both_framings

Every error the core returns ends the connection. The parsing and framing code reads peer-controlled offsets with get, take, take_array, checked_add or saturating_add rather than indexing, so malformed input becomes an error, not a panic.

Where Error Kind
AuthId state vmess: unknown user or invalid auth id PermissionDenied
Header envelope vmess: AEAD header open failed (a tag failure in gcm_open) 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, address unknown address type, empty domain name, non-utf8 domain, invalid domain name; a field past the end of the plaintext InvalidData; UnexpectedEof for the last
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 after the downlink ended vmess: downlink already ended InvalidData
Handshake deadline client did not complete its request in time TimedOut
Core staging short staging room below the core's declared reserve Other
Client response header vmess: unexpected response header, or vmess: AEAD header open failed InvalidData
Client codec staging short vmess: staging room below the declared reserve Other

Outbound failures are events, not errors: ConnectFailed and OutboundError finish a TCP or UDP connection and close only the affected sub-flow of a mux carrier. A transport EOF during AuthId or Header finishes quietly.

Run with cargo test -p etemenanki-protocols vmess.

File Tests
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 (partial buffers return None, a wrong response byte fails)
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 (a MAX_PAYLOAD + 1 write takes 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 (one read opens three chunks and completes two split frames; both are forwarded intact from the held buffer)

The core tests drive VMessCore through CoreHarness, which re-presents the unconsumed tail the way the runtime does, and use the real client codecs to produce the wire bytes, so every core test is also a codec test.