Trojan
Source files: 27 · checked against Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/trojan/mod.rsEtemenanki/protocols/src/trojan/protocol.rsEtemenanki/protocols/src/trojan/users.rsEtemenanki/protocols/src/trojan/core.rsEtemenanki/protocols/src/trojan/codec.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/mux/mod.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/helpers/crypto.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/flow.rsEtemenanki/protocols/src/mux/frame.rsEtemenanki/concepts/src/client.rsEtemenanki/protocols/tests/unit/trojan/protocol.rsEtemenanki/protocols/tests/unit/trojan/core.rsEtemenanki/protocols/tests/unit/trojan/codec.rsEtemenanki/protocols/tests/unit/mux/demux.rsEtemenanki/protocols/tests/pipeline/trojan.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/connector.rsEtemenanki/app/src/serve.rsEtemenanki/app/tests/integration/e2e_xray_mux.rskatana/src/inbound.rskatana/src/serve.rs
Trojan is a small authenticated proxy protocol. A client sends one header, which holds a password hash, a command and a target address. After that the connection carries either the target’s bytes or a stream of self-addressed UDP frames. The server never answers the header. It relays, or it closes.
This page covers the module protocols/src/trojan/: the wire primitives in protocol.rs, the user table in users.rs, the server core TrojanCore in core.rs and the two client codecs in codec.rs. It is written for contributors who change that code or build a new inbound or outbound on it. The code is a port of Xray’s proxy/trojan, and the wire format matches it byte for byte.
Responsibilities
Section titled “Responsibilities”| Piece | Does | Leaves to others |
|---|---|---|
protocol.rs |
Encodes and parses the request header and UDP packet frames, both from slices (sans-I/O) and from async streams. Computes the user key hex(SHA224(password)). |
Address encoding, which is AddressCodec::SOCKS in protocols/src/helpers/address.rs. |
users.rs → Validator |
Hashes each password once, at construction, and maps a wire hash to a user, comparing it with every entry in constant time. | Adding or removing users. A user change builds a new Validator. |
core.rs → TrojanCore |
Runs one connection as a state machine: header, optional sniffing, then a TCP relay, a UDP association or a mux.cool carrier. | I/O, dialing and routing. The runtime and its connector apply the effects that the core emits. |
codec.rs → TrojanStream, TrojanDatagram |
Implements the client side for a TCP flow and for a UDP association, over a stream to the upstream server. | Dialing the upstream and TLS. The client runtime and the transport layer own those. |
Trojan itself encrypts nothing. Confidentiality comes entirely from the transport under it (TLS, or WebSocket or gRPC inside TLS); see TCP and TLS transports.
Wire format
Section titled “Wire format”All multi-byte integers are big-endian. ADDR is AddressCodec::SOCKS, the SOCKS5 address layout with the port last.
Request header
Section titled “Request header”The client sends this once, at the start of the connection. The payload follows at once, without waiting for any reply.
| Field | Size | Meaning |
|---|---|---|
| User key | 56 (HASH_LEN) |
Lowercase hex of SHA224(password), as ASCII |
CRLF |
2 | \r\n |
| Command | 1 | 0x01 CMD_TCP_CONNECT, 0x03 CMD_UDP_ASSOCIATE |
| Address | 1 + 4, 1 + 16, or 2 + n | See below |
| Port | 2 | Target port |
CRLF |
2 | \r\n |
| Payload | rest of the stream | Target bytes (CONNECT) or UDP packet frames (ASSOCIATE) |
The longest possible header is REQUEST_HEADER_MAX = 56 + 2 + 1 + 259 + 2 = 320 bytes, where 259 is AddressCodec::MAX_LEN (type byte, length byte, a 255-byte domain and the port).
password_hash produces the user key. The unit test pins the value for the password password:
d63dc919e201d7bc4c825630d2cf25fdc93d4b2f0d46706d29038d01Address
Section titled “Address”| Type byte | Address bytes | Notes |
|---|---|---|
0x01 |
4 | IPv4 |
0x03 |
1 length byte, then that many bytes | Domain. Must be non-empty UTF-8. A string that starts with a digit or [ and parses as an IP address is folded into Remote::IpAddr; anything else may contain only ASCII letters, digits, -, . and _, or it fails with invalid domain name: …. |
0x04 |
16 | IPv6 |
Any other type byte fails with unknown address type: <n> (InvalidData).
UDP packet frame
Section titled “UDP packet frame”After a CMD_UDP_ASSOCIATE header, the address in the header is ignored. Every packet in either direction is framed on its own:
| Field | Size | Meaning |
|---|---|---|
| Address | variable | ADDR encoding. Uplink: the packet’s target. Downlink: the peer it came from. |
| Port | 2 | Target or source port |
| Length | 2 | Payload length, at most MAX_LENGTH = 8192 |
CRLF |
2 | \r\n |
| Payload | Length | One datagram |
A frame header is at most PACKET_HEADER_MAX = 259 + 2 + 2 = 263 bytes. A whole frame is therefore at most 263 + 8192 = 8455 bytes, which always fits the core’s 16 KiB read buffer.
Key types
Section titled “Key types”Wire primitives (protocol.rs)
Section titled “Wire primitives (protocol.rs)”pub const ADDR: AddressCodec = AddressCodec::SOCKS;pub const CRLF: [u8; 2] = [b'\r', b'\n'];pub const CMD_TCP_CONNECT: u8 = 0x01;pub const CMD_UDP_ASSOCIATE: u8 = 0x03;pub const HASH_LEN: usize = 56;pub const MAX_LENGTH: usize = 8192;pub const PACKET_HEADER_MAX: usize = AddressCodec::MAX_LEN + 2 + 2;pub const REQUEST_HEADER_MAX: usize = HASH_LEN + 2 + 1 + AddressCodec::MAX_LEN + 2;
pub fn password_hash(password: &str) -> [u8; HASH_LEN];pub fn command_for(network: DialNetwork) -> u8;
pub struct RequestHeader { pub hash: [u8; HASH_LEN], pub command: u8, pub destination: Destination,}
pub fn encode_request_header( hash: &[u8; HASH_LEN], command: u8, remote: &Remote, port: u16,) -> BytesMut;pub fn parse_request_header(buf: &[u8]) -> io::Result<Option<(RequestHeader, usize)>>;
pub struct Packet { pub dest: Destination, pub payload: Range<usize>, pub consumed: usize,}
pub fn packet_header(dest: &Destination, len: usize) -> BytesMut;pub fn packet_into(dest: &Destination, payload: &[u8], out: &mut Staging<'_>) -> Option<()>;pub fn parse_packet(buf: &[u8]) -> io::Result<Option<Packet>>;The slice parsers follow one contract: Ok(None) means “the buffer holds only part of a header or frame, deliver more”, Ok(Some(..)) returns the parsed item and how many bytes it took, and Err means the bytes can never become valid. parse_packet returns the payload as a Range into the input, so the core can forward it without copying.
parse_request_header returns the hash unvalidated. It sets the destination’s network from the command: DialNetwork::Tcp for CMD_TCP_CONNECT, DialNetwork::Udp for CMD_UDP_ASSOCIATE. The caller, TrojanCore, checks the hash against its users.
packet_into writes one whole frame into a Staging area or nothing. It returns None when the room is short or the payload is longer than u16::MAX.
The module also has async stream variants, which mirror Xray’s ConnReader and PacketReader:
pub async fn write_request_header<W: AsyncWrite + Unpin>( w: &mut W, hash: &[u8; HASH_LEN], command: u8, remote: &Remote, port: u16,) -> io::Result<()>;pub async fn read_request_header<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<RequestHeader>;pub async fn write_packet<W: AsyncWrite + Unpin>( w: &mut W, remote: &Remote, port: u16, payload: &[u8],) -> io::Result<()>;pub async fn read_packet<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<(Destination, Bytes)>;Only the unit tests in protocols/tests/unit/trojan/protocol.rs use them, as a reference round trip against the slice encoders. The core and the codecs use the slice functions.
Users and Validator (users.rs)
Section titled “Users and Validator (users.rs)”pub struct TrojanUser { pub password: CompactString, pub email: CompactString,}
pub struct TrojanServerConfig<T> { pub users: Vec<(TrojanUser, Arc<T>)>,}
pub struct Validator<T> { users: Vec<UserEntry<T>>,}
pub struct UserEntry<T> { pub hash: [u8; HASH_LEN], pub email: CompactString, pub data: Arc<T>,}
impl<T> Validator<T> { pub fn new(users: &[(TrojanUser, Arc<T>)]) -> Self; pub fn get(&self, hash: &[u8]) -> Option<&UserEntry<T>>;}T is the per-user payload that is carried into routing. etemenanki-app uses (). katana uses its UserTag, and its Trojan node builds each TrojanUser with the panel user’s UUID as the password (the XrayR convention) and a traffic label as the email.
Validator::new computes password_hash for every user once. The plaintext password is not kept.
Validator::get compares the wire hash with every entry using ct_eq from protocols/src/helpers/crypto.rs:
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool;ct_eq is a length check followed by subtle::ConstantTimeEq, so a key that shares a long prefix with a stored one costs the same comparison as one that shares none. The loop has no early return: it visits every entry whatever the outcome. If two users share a password, the later entry wins. The lookup is a linear scan, so its cost grows with the number of users.
TrojanServerConfig is a plain holder for the user list. The pipeline tests build their Validator from it; the app and katana build the list directly.
TrojanCore (core.rs)
Section titled “TrojanCore (core.rs)”pub struct TrojanCore<T> { validator: Arc<Validator<T>>, sniff: bool, source: Option<IpAddr>, timing: Timing, prefix: SniffPrefix, state: State<T>,}
impl<T> TrojanCore<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 TrojanCore<T> { type Key = FlowKey; type Target = Flow<T>; type Error = io::Error; type TransportAddr = ();
const STAGING_RESERVE: usize = PACKET_HEADER_MAX.next_multiple_of(16) + downlink_overhead(Self::BUF_SIZE); const MAX_DATAGRAM: usize = super::protocol::MAX_LENGTH;
fn handle( &mut self, event: Event<'_, Self>, fx: &mut Effects<'_, Self>, ) -> Result<usize, io::Error>; fn held(&self) -> &[u8];}The core implements ProxyCoreDecode from etemenanki-concepts; the trait and its event and effect vocabulary are described in Server core. It uses these shared helpers from protocols/src/core/mod.rs:
| Helper | Role in TrojanCore |
|---|---|
Timing |
The single deadline, armed per Phase: handshake, sniff window, relay idle. |
SniffPrefix |
Holds the first payload bytes of a CONNECT to an IP while the TLS and HTTP sniffers look for a domain. |
Passthrough<FlowKey> |
The half-close bookkeeping of the TCP relay. |
FlowKey |
Direct for the connection’s own flow, Sub(SubKey) for a mux sub-flow. |
staging_full, handshake_timed_out |
The two core-level errors. |
sniff comes from the inbound’s sniffing setting. source is the client’s IP, when the inbound knows it, and ends up in every Flow for routing.
The private state enum is the heart of the core:
enum State<T> { Handshake, Sniff(Flow<T>), Tcp(Passthrough<FlowKey>), Udp { flow: Flow<T>, opened: bool }, Mux(Demux<T>), Done,}The identity the core attaches to a flow is built by a private user method:
NetworkUser { authorization: UserAuthorization::UsernamePassword { username: CompactString::from(email), password: CompactString::default(), }, user_data: data,}The username is the user’s email, which may be empty. The password field is always empty, so no credential travels into routing, logs or the connector.
Client codecs (codec.rs)
Section titled “Client codecs (codec.rs)”pub struct TrojanStream { header: BytesMut,}impl TrojanStream { pub fn new(hash: &[u8; HASH_LEN], dest: &Destination) -> Self;}impl ProxyCoreEncodeHandshake for TrojanStream { type Target = Destination; type Error = io::Error; const STAGING_RESERVE: usize = RESERVE; // REQUEST_HEADER_MAX fn start(&mut self, out: &mut Staging<'_>) -> io::Result<Handshake>; fn reply(&mut self, _: &mut [u8], _: &mut Staging<'_>) -> io::Result<Reply>; fn finish(&mut self, _: &mut Staging<'_>) -> io::Result<()>;}impl ProxyCoreEncode for TrojanStream { fn seal(&mut self, plain: &[u8], out: &mut Staging<'_>) -> io::Result<usize>; fn open(&mut self, wire: &mut [u8]) -> io::Result<Opened>;}
pub struct TrojanDatagram { header: BytesMut,}impl TrojanDatagram { pub fn new(hash: &[u8; HASH_LEN]) -> Self;}impl ProxyCoreEncodeDatagram for TrojanDatagram { fn seal_to( &mut self, plain: &[u8], to: &Destination, out: &mut Staging<'_>, ) -> io::Result<Option<()>>; fn open_from(&mut self, wire: &mut [u8]) -> io::Result<OpenedFrom>;}TrojanDatagram also implements ProxyCoreEncodeHandshake the same way TrojanStream does. Both codecs take the user key, not the password, so the caller hashes once. The app’s Trojan outbound calls password_hash when the config is built and moves the key into the closure that makes a codec per flow.
| Method | TrojanStream |
TrojanDatagram |
|---|---|---|
new |
Encodes a CMD_TCP_CONNECT header for dest |
Encodes a CMD_UDP_ASSOCIATE header with the placeholder address 0.0.0.0:0, as Xray does |
start |
Stages the header, clears it, returns Handshake::Done |
Same |
reply |
Always Err: trojan: the server sends no handshake reply |
Same |
finish |
Stages nothing: the wire’s own EOF carries the half-close | Same |
seal / seal_to |
Copies plaintext verbatim | Stages one packet frame with packet_into, or returns None |
open / open_from |
Returns the whole slice as one frame; NeedMore on an empty slice |
Parses one frame with parse_packet and returns its source as the peer |
Because start returns Handshake::Done, the client runtime never calls reply. The Err is a guard for a runtime that would.
Data flow
Section titled “Data flow”Server state machine
Section titled “Server state machine”stateDiagram-v2 [*] --> Handshake Handshake --> Udp: command 0x03 Handshake --> Mux: CONNECT to v1.mux.cool Handshake --> Sniff: CONNECT to an IP, sniff on Handshake --> Tcp: CONNECT otherwise Sniff --> Tcp: found, exhausted, deadline or EOF Handshake --> Done: transport EOF Udp --> Done: EOF or outbound gone Mux --> Done: transport EOF Tcp --> [*]: both halves closed or outbound gone Done --> [*]
Every arrow out of Handshake is taken only after the whole header is parsed and Validator::get has returned a user. An unknown user, a malformed header or the handshake deadline returns Err from handle instead, and the runtime ends the connection. No transition exists for those cases.
| State | Timing phase on entry |
Deadline |
|---|---|---|
Handshake |
Phase::Handshake, armed by the first Event::Transport |
HANDSHAKE_TIMEOUT, 10 s |
Sniff |
Phase::Sniff |
SNIFF_TIMEOUT, 300 ms |
Tcp, Udp, Mux |
Phase::Relay |
RELAY_IDLE_TIMEOUT, 300 s, re-armed by every byte event |
is_established() becomes true on entry to Tcp, Udp or Mux, and not in Sniff. A UDP association is therefore established as soon as its header is parsed, before any packet has arrived.
Handshake
Section titled “Handshake”on_transport in State::Handshake does this:
- It calls
parse_request_headeron the unparsed read buffer.Ok(None)returnsOk(0): nothing is consumed, and the runtime reads more. - It calls
self.validator.get(&header.hash). With no match it returnsPermissionDenied,trojan: invalid user. - It builds
Flow::new(header.destination, user, self.source). - It branches on the command and destination, in this order:
CMD_UDP_ASSOCIATE, thencrate::mux::is_mux_destination, thenself.sniff && worth_sniffing(..), then plain CONNECT. - It returns
Ok(used), the header length only. Payload bytes in the same read stay in the buffer, and the runtime callshandleagain with them, now in the new state.
TCP CONNECT
Section titled “TCP CONNECT”sequenceDiagram participant C as Client participant R as Server runtime participant K as TrojanCore participant O as Outbound C->>R: header + first payload R->>K: Event::Transport K-->>R: Open Direct, SetDeadline 300 s R->>O: connector dials the routed outbound R->>K: Event::Transport (payload) K-->>R: Forward Direct 0..n R->>O: write payload after the connect O->>R: reply bytes R->>K: Event::Outbound K-->>R: stage verbatim R->>C: reply bytes
open_tcp pushes Effect::Open { key: FlowKey::Direct, target: flow }, then Effect::ForwardHeld over the sniffed prefix if any, and moves to State::Tcp(Passthrough::new(FlowKey::Direct)). Forwards pushed before the connect completes wait in the runtime’s effect queue. Trojan sends nothing to the client on success or on failure: the first bytes the client sees are the target’s.
With sniffing, State::Sniff feeds each read into SniffPrefix::push and consumes exactly what it took (at most SNIFF_LIMIT, 4096 bytes in total). When the verdict is Found or Exhausted, or on the sniff deadline or transport EOF, open_sniffed sets flow.sniffed from the prefix and calls open_tcp, which forwards the held bytes with ForwardHeld. worth_sniffing is true only for Remote::IpAddr, so a domain target goes straight to open_tcp. Sniffing itself is described in Sniffing.
UDP association
Section titled “UDP association”sequenceDiagram participant C as Client participant K as TrojanCore participant L as Datagram outbound C->>K: header, command 0x03 K-->>K: State::Udp, opened = false, Relay phase C->>K: frame to A K-->>L: Open Direct toward A (first frame only) K-->>L: SendTo A, payload range C->>K: frame to B K-->>L: SendTo B, payload range L->>K: Event::Datagram from A K-->>C: packet_into: frame with source A C->>K: transport EOF K-->>L: Close Direct K-->>C: ShutdownTransport, Finish
The association’s Flow keeps the header’s destination until the first frame arrives. On that frame the core pushes Effect::Open with flow.toward(packet.dest): the first packet’s destination, the same user and source, and sniffed: None. It then sets opened = true. Every frame, the first included, becomes one Effect::SendTo { key: FlowKey::Direct, to: packet.dest, range }, where range points into the event slice, so the payload is never copied. One Event::Transport may carry many frames. The loop parses until parse_packet returns Ok(None) and reports the bytes of complete frames as consumed. A trailing partial frame stays in the read buffer.
Only one outbound is opened per association. Routing each packet is the connector’s job. In etemenanki-app, AppConnector answers a UDP Open with a FanOutLink that routes every packet on its own destination; see Outbounds.
A reply arrives as Event::Datagram { from, data, .. }. The core frames it with packet_into(&from, data, fx.staging()), so the client learns which peer answered.
mux.cool carrier
Section titled “mux.cool carrier”Trojan has no mux command. A multiplexing client, such as Xray with mux.enabled, sends an ordinary CONNECT to the domain v1.mux.cool. is_mux_destination compares only the domain, ignoring ASCII case, and ignores the port (Xray’s client dials MUX_PORT = 9527). The core then moves to State::Mux(Demux::new(flow, self.sniff)).
sequenceDiagram participant C as Mux client participant K as TrojanCore participant D as Demux participant S as Sub-flow outbound C->>K: header to v1.mux.cool, then mux frames K->>D: feed(data, 0, fx) D-->>S: Open Sub(id, generation), Forward K-->>C: stage take_out() (declines) S->>K: Event::Outbound Sub K->>D: on_outbound K-->>C: stage out() (Keep frames)
From here on the core is a thin adapter. Event::Transport goes to Demux::feed, and any End frames the demultiplexer queued to decline a session are taken with take_out() and staged. feed clears the demultiplexer’s output buffer before it parses, so that buffer holds only this read’s declines: a downlink frame that an earlier sub-flow event already staged is never sent a second time. feed reports only whole frames as consumed, so a frame split across reads stays in the runtime’s read buffer, as a partial UDP frame does. Outbound bytes, datagrams and EOFs on a FlowKey::Sub key go to on_outbound, on_datagram and on_outbound_gone, each of which replaces the output buffer with its own frames, and the core stages them by reading out() in place. held() returns Demux::held(); that buffer is used only by Demux::feed_chunks (the VMess carrier), so for Trojan it stays empty. The carrier’s own Flow supplies the user and source of every sub-flow. With sniffing on, a sub-flow to an IP is sniffed from the payload of its New frame only, since the carrier is not held back for one sub-flow. The mux frame format, MAX_SESSIONS and XUDP are described in mux.cool and XUDP.
Client side
Section titled “Client side”sequenceDiagram participant P as Plaintext side participant R as ProxyClientRuntime participant K as TrojanStream participant U as Upstream server R->>U: dial through the transport R->>K: start K-->>R: header staged, Handshake::Done R->>U: write and flush the header P->>R: poll_write(plain) R->>K: seal, copies verbatim R->>U: bytes U->>R: bytes R->>K: open, whole slice is one frame R->>P: plaintext
The connector’s future resolves only after the header has been written and flushed, so the server side of a chained proxy sees “connected” once the header has left, not when the target answers. In etemenanki-app, the Trojan outbound is a ProxyClient<TROJAN_BUF, TrojanStream, TrojanDatagram> with TROJAN_BUF = 16 KiB. It picks TrojanDatagram when the flow’s network is DialNetwork::Udp and TrojanStream otherwise. Each UDP association opens its own connection to the server. The runtime is described in Client runtime.
Invariants
Section titled “Invariants”| Invariant | Mechanism | Pinned by |
|---|---|---|
| A header split across reads is parsed only once it is whole, and nothing is opened before that. | parse_request_header returns Ok(None) at every short cut, and the core returns Ok(0). |
request_header_is_parsed_from_a_slice_once_whole (unit/trojan/protocol.rs), header_split_across_events_opens_once_complete (unit/trojan/core.rs) |
| Only the header is consumed. Payload that arrives in the same read reaches the target intact. | on_transport returns used, and the runtime redelivers the rest. |
header_split_across_events_opens_once_complete, tcp_request_roundtrip (unit/trojan/protocol.rs) |
| A preface without CRLF at offset 56 fails as soon as 58 bytes are buffered, instead of waiting for more. | The CRLF check at offset HASH_LEN returns InvalidData. |
rejects_missing_crlf, request_header_is_parsed_from_a_slice_once_whole |
| An unknown key opens nothing and writes nothing. | Validator::get returns None, and handle returns PermissionDenied before any effect. |
unknown_user_is_refused (unit/trojan/core.rs) |
| The user lookup compares the wire key with every entry. | ct_eq (subtle) per entry, in a loop with no early return. |
Not pinned by a test; password_hash_vector pins the key the comparison runs on. |
| The server never answers the header. | The handshake branch pushes effects but stages nothing. Later staging is only relayed outbound bytes, framed datagrams or mux frames. A failed connect goes through Passthrough::on_outbound_gone, which stages nothing. |
connect_failure_ends_the_connection_without_a_reply (unit/trojan/core.rs) |
| The client never waits for a reply. | start returns Handshake::Done, and reply is an error. |
stream_codec_sends_the_header_then_passes_bytes_through (unit/trojan/codec.rs) |
| A UDP association opens exactly one outbound, on its first frame. | The opened flag in State::Udp. |
udp_association_opens_on_the_first_packet_and_frames_replies (unit/trojan/core.rs) |
| Every valid UDP frame fits the read buffer, so a frame can never stall the parser. | parse_packet rejects a length above MAX_LENGTH before it waits for the payload; 8455 is less than BUF_SIZE. |
rejects_oversize_udp_packet, udp_packets_are_parsed_by_range (unit/trojan/protocol.rs) |
| A partial UDP frame is kept, not dropped. | parse_packet returns Ok(None), and the loop reports only complete frames as consumed. |
a_partial_packet_frame_waits_for_the_rest (unit/trojan/core.rs) |
| A reply frame names the peer that sent it. | packet_into(&from, ..) in the Event::Datagram arm. |
udp_association_opens_on_the_first_packet_and_frames_replies, new_server_vs_new_client_udp (pipeline/trojan.rs) |
| Staging never overflows while serving an event. | STAGING_RESERVE covers one frame header (263, rounded up to 272) plus the mux Keep headers of one BUF_SIZE read (536), 808 bytes in all. The runtime delivers an event only with that room plus the payload free. A breach is staging_full, a core bug. |
udp_packets_are_parsed_by_range asserts that PACKET_HEADER_MAX covers a 255-byte domain. |
A sniffed prefix outlives its ForwardHeld. |
SniffPrefix::clear runs only at the next byte event, which the runtime delivers after the held forward is applied. |
sniffing_an_ip_target_holds_the_prefix_until_a_host_is_found (unit/trojan/core.rs) |
| Domain targets, UDP associations and mux carriers are never sniffed as a whole. | worth_sniffing in the handshake branch; the UDP and mux branches come first. |
a_domain_target_is_never_sniffed; udp_association_opens_on_the_first_packet_and_frames_replies runs with sniffing on. |
Each mux downlink frame is staged once. An uplink read stages only the End frames it declined sessions with. |
Demux::feed clears the output buffer before parsing, and every sub-flow call replaces it, so frames the core staged by reading out() are not queued again. |
a_downlink_frame_is_not_sent_again_by_the_next_uplink (unit/mux/demux.rs) |
A CONNECT to v1.mux.cool is a carrier, whatever the port. |
is_mux_destination compares only the domain. |
trojan_carries_mux_when_the_connect_names_the_carrier (unit/mux/demux.rs), trojan_mux_tcp_single_stream (app integration/e2e_xray_mux.rs) |
The password never reaches a Flow. |
UserEntry stores only the hash, and user() sets an empty password. |
header_split_across_events_opens_once_complete checks the username |
| The client emits its header exactly once. | start clears header after staging it. |
Not pinned directly; stream_codec_sends_the_header_then_passes_bytes_through and datagram_codec_frames_each_packet_with_its_peer (unit/trojan/codec.rs) parse back the one header start stages. |
Failure paths and cancellation
Section titled “Failure paths and cancellation”handle returning Err ends the connection: the runtime stops, and the app logs one debug line with the error text. Effects decide every other ending.
| Event | State | Result |
|---|---|---|
Transport, malformed header |
Handshake |
Err, InvalidData: trojan: not trojan protocol (missing CRLF after hash), or an address error such as unknown address type: <n> |
Transport, unknown key |
Handshake |
Err, PermissionDenied: trojan: invalid user |
Deadline |
Handshake |
Err, TimedOut: client did not complete its request in time |
Deadline |
Sniff |
open_sniffed with whatever was collected |
Deadline |
Tcp, Udp, Mux |
Timing::expired moves to Phase::Closing and pushes Finish |
TransportEof |
Handshake, Done |
Finish |
TransportEof |
Sniff |
Opens, then Shutdown { Direct } through the relay |
TransportEof |
Tcp |
Shutdown { Direct }; Finish once the outbound has also ended |
OutboundEof |
Tcp |
ShutdownTransport; Finish once the transport has also ended |
TransportEof |
Udp |
Close { Direct } if opened, then ShutdownTransport, Finish |
TransportEof |
Mux |
Close for every live sub-flow, then ShutdownTransport, Finish |
ConnectFailed, OutboundError |
Tcp |
Passthrough::on_outbound_gone: ShutdownTransport, Finish, nothing staged |
ConnectFailed, OutboundError |
Udp |
ShutdownTransport, Finish |
ConnectFailed, OutboundError, OutboundEof on a Sub key |
Mux |
Demux::on_outbound_gone: an End frame and Close for that sub-flow; the carrier stays |
SendFailed |
any | Logged at debug as trojan: packet to … dropped: …; the association continues |
Transport, frame length above 8192 |
Udp |
Err, InvalidData: trojan: oversize payload; the whole association ends |
TrojanCore never sets a deadline in the Handshake state until the client has sent something, because the runtime delivers no event before the first read. A client that connects and stays silent is caught outside the core. Until is_established() is true, the app’s drive in app/src/serve.rs waits at most HANDSHAKE_TIMEOUT for each runtime step, and fails with inbound handshake timed out after 10s when one does not come; see Serving. katana’s drive in src/serve.rs bounds the whole handshake of a TrojanCore<UserTag> by HANDSHAKE_TIMEOUT instead.
Cancellation is structural. The core owns no task, channel or timer. Dropping the runtime drops the core, its held bytes, the Demux and every outbound link the runtime holds.
On the client side, TrojanDatagram::open_from runs the same parse_packet. A frame from the server that is malformed or longer than MAX_LENGTH is an InvalidData error, and the link fails. Before seal_to, the client runtime makes room for STAGING_RESERVE plus the packet. It fails with frame larger than the client runtime's buffer if the packet cannot fit even in an empty buffer. After that, seal_to always fits.
The client codec does not apply MAX_LENGTH. With the app’s 16 KiB TROJAN_BUF, a packet of 8193 to 16064 bytes is sealed and sent, and a TrojanCore server rejects that frame with trojan: oversize payload, which ends the whole association.
Limits
Section titled “Limits”| Constant | Value | Where | Meaning |
|---|---|---|---|
HASH_LEN |
56 bytes | protocol.rs |
Hex SHA-224 user key |
REQUEST_HEADER_MAX |
320 bytes | protocol.rs |
Longest request header; also the codec RESERVE |
PACKET_HEADER_MAX |
263 bytes | protocol.rs |
Longest UDP frame header |
MAX_LENGTH |
8192 bytes | protocol.rs |
Largest UDP payload per frame |
TrojanCore::BUF_SIZE |
16384 bytes | core.rs |
Read, staging and scratch buffers per connection |
TrojanCore::STAGING_RESERVE |
808 bytes | core.rs |
272 for one frame header plus 536 for mux downlink headers |
TrojanCore::MAX_DATAGRAM |
8192 bytes | core.rs |
Largest outbound datagram delivered whole; the runtime truncates beyond it |
HANDSHAKE_TIMEOUT |
10 s | protocols/src/core/mod.rs |
Header deadline, armed at the first read |
SNIFF_TIMEOUT |
300 ms | protocols/src/sniff/mod.rs |
Sniffing window |
SNIFF_LIMIT |
4096 bytes | protocols/src/sniff/mod.rs |
Sniffing budget |
RELAY_IDLE_TIMEOUT |
300 s | protocols/src/core/mod.rs |
Idle limit for TCP, UDP and mux alike |
MAX_SESSIONS |
256 | protocols/src/mux/demux.rs |
Sub-flows per mux carrier |
TROJAN_BUF |
16384 bytes | app/src/outbound/mod.rs |
Client runtime buffers in the app’s Trojan outbound |
| Test | File | What it pins |
|---|---|---|
password_hash_vector |
protocols/tests/unit/trojan/protocol.rs |
56 lowercase hex bytes, equal to Xray’s hexSha224("password") |
tcp_request_roundtrip |
same | Encode and parse a CONNECT header; the payload after it survives (mirrors Xray’s TestTCPRequest) |
udp_request_roundtrip |
same | ASSOCIATE header plus one packet frame (mirrors TestUDPRequest) |
rejects_missing_crlf |
same | A wrong separator after the hash is an error |
rejects_oversize_udp_packet |
same | Length MAX_LENGTH + 1 is an error |
request_header_is_parsed_from_a_slice_once_whole |
same | Ok(None) at six cut points, exact consumed, and CRLF rejection on the slice parser |
udp_packets_are_parsed_by_range |
same | Back-to-back frames, payload ranges, the PACKET_HEADER_MAX bound, oversize rejection |
header_split_across_events_opens_once_complete |
protocols/tests/unit/trojan/core.rs |
Handshake deadline, Open target (port, network, source, username), idle deadline, then Forward |
unknown_user_is_refused |
same | PermissionDenied |
sniffing_an_ip_target_holds_the_prefix_until_a_host_is_found |
same | Sniff deadline, prefix across two reads, ForwardHeld 0..41, prefix cleared at the next event |
sniff_deadline_opens_with_what_was_collected |
same | Deadline in Sniff opens with sniffed: None and forwards the held bytes |
a_domain_target_is_never_sniffed |
same | A domain CONNECT opens at once |
relay_half_closes_and_finishes |
same | Verbatim downlink, ShutdownTransport on outbound EOF, Shutdown and Finish on transport EOF |
connect_failure_ends_the_connection_without_a_reply |
same | ShutdownTransport, Finish, nothing staged |
udp_association_opens_on_the_first_packet_and_frames_replies |
same | Established at the header, one Open, one SendTo per frame, framed reply, close order |
a_partial_packet_frame_waits_for_the_rest |
same | Nothing consumed for half a frame |
handshake_deadline_fails_the_connection |
same | TimedOut |
stream_codec_sends_the_header_then_passes_bytes_through |
protocols/tests/unit/trojan/codec.rs |
Header parses back, verbatim seal and open, reply is an error |
datagram_codec_frames_each_packet_with_its_peer |
same | ASSOCIATE header, framed seal_to, None with nothing staged when room is short, open_from waits for a whole frame |
trojan_carries_mux_when_the_connect_names_the_carrier |
protocols/tests/unit/mux/demux.rs |
CONNECT to v1.mux.cool:9527 becomes a carrier; sub-flow open, forward, framed reply, close |
a_downlink_frame_is_not_sent_again_by_the_next_uplink |
same | The Trojan and VLESS staging pattern: a UDP reply read from out() in place, then an uplink that declines nothing, which leaves take_out() empty |
new_server_vs_new_client_tcp |
protocols/tests/pipeline/trojan.rs |
Real runtimes over loopback: a 70,000-byte echo and a clean half-close |
new_server_vs_new_client_udp |
same | UDP echo of 8 and 1500 bytes, with the reply attributed to the echo server |
trojan_mux_tcp_single_stream |
app/tests/integration/e2e_xray_mux.rs |
A real Xray client with mux on, over ws and TLS, against the app’s Trojan inbound |
Run them from the Etemenanki workspace:
cargo test -p etemenanki-protocols trojancargo test -p etemenanki-protocols a_downlink_frame_is_not_sent_againcargo test -p etemenanki-app trojan_muxThe first command runs the unit tests, the pipeline tests and the Trojan carrier test in unit/mux/demux.rs, because their names or paths contain trojan. The second runs the mux staging test, whose name does not. The third builds Xray from the reference tree with Go, and skips itself with a SKIP: line when Go is not installed. How the test suites are organised is described in Testing.