VMess: wire format and core
Source files: 26 · checked against Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/vmess/mod.rsEtemenanki/protocols/src/vmess/protocol.rsEtemenanki/protocols/src/vmess/framing.rsEtemenanki/protocols/src/vmess/session.rsEtemenanki/protocols/src/vmess/core.rsEtemenanki/protocols/src/vmess/codec.rsEtemenanki/protocols/src/vmess/aead.rsEtemenanki/protocols/src/vmess/keys.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/serve.rsEtemenanki/protocols/tests/unit/vmess/protocol.rsEtemenanki/protocols/tests/unit/vmess/framing.rsEtemenanki/protocols/tests/unit/vmess/codec.rsEtemenanki/protocols/tests/unit/vmess/core.rsEtemenanki/protocols/tests/unit/mux/demux.rsEtemenanki/protocols/tests/pipeline/vmess.rsEtemenanki/app/tests/integration/e2e_xray_vmess.rsEtemenanki/app/tests/integration/e2e_xray_mux.rsEtemenanki/app/tests/support/mod.rskatana/src/outbound/mod.rskatana/src/config.rskatana/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.
Responsibilities
Section titled “Responsibilities”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.
The exchange
Section titled “The exchange”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.
Request header
Section titled “Request header”Layout
Section titled “Layout”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.
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.
Validation
Section titled “Validation”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.
-
Floor. Fewer than 42 bytes (the 38 fixed bytes plus the checksum) fails with
vmess: request header too short. -
Checksum. The last four bytes must equal
fnv1a32of everything before them, or the parser fails withvmess: request header checksum mismatch. The checksum is checked before any field is read. -
Version. Byte 0 must be
VERSION; anything else fails withvmess: unsupported version <n>. -
Options.
RequestOptions::from_wirerefuses global padding without chunk masking:vmess: global padding negotiated without chunk masking. -
Security. The low nibble of byte 35 must be
3or4.Security::from_bytefails any other value withvmess: unsupported security type <n>, which covers Xray’snone(5),zero(6) and the legacy values. -
Command. Byte 37 must be
0x01,0x02or0x03;Command::from_bytefails any other value withvmess: unsupported command <n>. -
Address. For TCP and UDP,
AddressCodec::VMESS.read_slicedecodes 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. -
Exact length.
38 + address + P + 4must equal the plaintext length exactly, computed with checked arithmetic. Any surplus or shortfall fails withvmess: request header length mismatch. This is what stops trailing bytes from hiding inside an authenticated header.
Response header
Section titled “Response 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.
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.
Security and options
Section titled “Security and options”Security
Section titled “Security”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) |
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).
Options
Section titled “Options”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 |
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 withio::ErrorKind::InvalidInput,vmess: global padding requires chunk masking.RequestOptions::from_wirefails the same bits withio::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 throughOutboundSession::new.
Body chunk framing
Section titled “Body chunk framing”protocols/src/vmess/framing.rs turns plaintext into chunks and back, one ChunkStream per direction.
Chunk layout
Section titled “Chunk layout”| 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.
One chunk, step by step
Section titled “One chunk, step by step”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::newseedsaead::Shake128with the full 16-byte body IV of that direction.next_padding_lenisnext_u16() % 64, so padding is 0 to 63 bytes;next_u16reads two keystream bytes big-endian. When both options are on, the padding length is drawn before the mask, as Xray’sAuthenticationWriterdoes. - Nonce. The 12-byte nonce starts as the first 12 bytes of the body IV.
next_nonceoverwrites bytes 0 and 1 with the chunk counter, big-endian, then increments the counter withwrapping_add. The counter is au16, so the nonce iscount || 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).
Key types
Section titled “Key types”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).
ChunkDecoder
Section titled “ChunkDecoder”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:
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.
Session keys
Section titled “Session keys”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.
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.
Server core: VMessCore
Section titled “Server core: VMessCore”Key types
Section titled “Key types”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.
nowis the clock the auth id’s timestamp is checked against: theAuthIdstate callsself.validator.authenticate(&authid, (self.now)()). It is a plainfn() -> i64rather 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 passaead::now_unix. The clock is used only for that check; the client side stamps its auth id withnow_unixdirectly insideaead::seal_vmess_aead_header. Tis the per-user data the account table carries. It reaches the outbound asNetworkUser { authorization: UserAuthorization::Uuid(uuid), user_data }inside theFlow, which is how katana attributes traffic to a panel user.sourceis the client’s IP, copied into everyFlowthe core opens, including the sub-flows of a mux carrier.sniffturns on destination sniffing for TCP requests to an IP address and is handed on to the mux demultiplexer.
State machine
Section titled “State machine”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>.
AuthId
Section titled “AuthId”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.
Header
Section titled “Header”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:
- parses the plaintext with
parse_request_header; - builds both chunk streams with
chunk_streams, wrapping the request stream in aChunkDecoder; - seals the response header with
response_headerand keeps it inresponseuntil it is due; - builds the
Flowfrom the destination, the user andsource; - 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:
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.
Deadlines
Section titled “Deadlines”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.
Buffer sizes
Section titled “Buffer sizes”| 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.
Client codecs
Section titled “Client codecs”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.
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>;}startstages the whole sealed header (auth id included) and returnsHandshake::Done, so plaintext flows at once.sealtakes at mostMAX_PAYLOADbytes per call and seals them as one chunk. The runtime calls it again for the rest.openfirst opens the response header withdecode_response_header_sliceand reports it asOpened::Framewith an emptyplainrange; after that it mapsChunkSteptoOpenedone to one.finishstages the terminator chunk.replyis never called, becausestartreturnsDone; it fails withvmess: the response header opens with the first frame.
- The header’s command is
Command::Udpand its destination is the flow’s target. seal_toseals one packet as one chunk and returnsOk(None)without staging anything when the room is less than the packet plusCHUNK_OVERHEAD_MAX. Thetoargument is ignored: a VMess UDP association has exactly one target, fixed in the header.open_fromattributes every non-empty frame to that target and returnsNoneas the source for the response header and the terminator.start,replyandfinishbehave as inVMessStream.
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.
Invariants
Section titled “Invariants”| 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 |
Failure paths
Section titled “Failure paths”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.
protocols/tests/pipeline/vmess.rs runs the server core in the real runtime over loopback sockets against the client codecs in the client runtime. Run with cargo test -p etemenanki-protocols --test pipeline vmess.
| Test | Covers |
|---|---|
new_server_vs_new_client_tcp |
100 000 bytes echoed with AES-128-GCM and padding, then ChaCha20-Poly1305 without padding; a clean half-close with nothing after it |
new_server_vs_new_client_udp |
A short packet and a 4000-byte packet echoed through a UDP echo server, with the reply’s source reported as the header’s target |
app/tests/integration/e2e_xray_vmess.rs and app/tests/integration/e2e_xray_mux.rs run the etemenanki-app binary against Xray built from the reference tree. They need go; without it, or when the build fails, they print a SKIP: line and pass. Run with cargo test -p etemenanki-app --test integration vmess.
| Test | Setup |
|---|---|
app_server_vmess_grpc_xray_client_tls |
App server over gRPC and TLS, Xray client with aes-128-gcm |
app_server_vmess_ws_xray_client_early_data_plain |
App server over WebSocket with early data, Xray client |
app_client_vmess_ws_xray_server_early_data_plain |
App client (global padding on) against an Xray server |
vmess_mux_tcp_single_stream |
Xray client with mux, app server; mux frames inside the chunk stream |
vmess_mux_payload_spans_both_framings |
64 KiB, past one chunk and one mux data block, so both framings split independently |
vmess_xudp_datagram_roundtrip |
UDP over XUDP on a VMess mux carrier |