Skip to content

Sniffing

Source files: 51 · checked against Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/sniff/collector.rs
  • Etemenanki/protocols/src/sniff/tls.rs
  • Etemenanki/protocols/src/sniff/http.rs
  • Etemenanki/concepts/src/sniff.rs
  • Etemenanki/concepts/src/core.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/protocols/src/lib.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/http/core.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/src/hysteria/server/inbound.rs
  • Etemenanki/protocols/src/trojan/core.rs
  • Etemenanki/protocols/src/vless/core.rs
  • Etemenanki/protocols/src/vmess/core.rs
  • Etemenanki/protocols/src/ss_legacy/core.rs
  • Etemenanki/protocols/src/ss_2022/core.rs
  • Etemenanki/protocols/src/tun/inbound.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/inbound/tun.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/router.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/outbound/udp_fanout.rs
  • Etemenanki/protocols/tests/unit/sniff/mod.rs
  • Etemenanki/protocols/tests/unit/sniff/collector.rs
  • Etemenanki/protocols/tests/unit/sniff/tls.rs
  • Etemenanki/protocols/tests/unit/sniff/http.rs
  • Etemenanki/protocols/tests/unit/core/mod.rs
  • Etemenanki/protocols/tests/unit/http/core.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/inbound.rs
  • Etemenanki/protocols/tests/unit/trojan/core.rs
  • Etemenanki/protocols/tests/unit/vless/core.rs
  • Etemenanki/protocols/tests/unit/vmess/core.rs
  • Etemenanki/protocols/tests/unit/ss_legacy/core.rs
  • Etemenanki/protocols/tests/unit/ss_2022/core.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/pipeline/tun.rs
  • Etemenanki/concepts/tests/runtime.rs
  • Etemenanki/environment/tests/unit/routing.rs
  • Etemenanki/app/tests/integration/e2e_sniff.rs
  • katana/src/config.rs
  • katana/src/manager/node.rs
  • katana/src/inbound.rs
  • katana/src/serve.rs
  • katana/src/router.rs
  • katana/src/connector.rs

A proxy request often names only an IP address. A TUN client always does, and so does a browser or proxy client that resolved the name before it connected. Such a flow cannot match any rule written in domains, geosite lists included. Sniffing reads the first bytes the client sends through the tunnel, recovers the name it is really addressing (the SNI of a TLS ClientHello, or the host of an HTTP/1.x request) and gives that name to the router as a second domain to match.

This page follows the mechanism from the two parsers up: the byte budget and the time window, the clock-free Collector, how a sans-I/O core holds the collected prefix and forwards it after the connect, the protocols that must answer the client before they can sniff, mux sub-flows, and the one place the result is read. Read it before you add a sniffer, add sniffing to a new core, or change what the router does with Flow::sniffed.

Sniffing fills Flow::sniffed for a flow whose destination is a bare IP, and nothing else. By design it does not:

  • fail a connection. Every parser returns Option. Malformed, truncated or unrecognised bytes yield None, and the flow is then routed on its destination as if sniffing were off.
  • change the destination. The outbound dials the address the client asked for. Only the router reads the sniffed name.
  • inspect a flow that already names a domain. worth_sniffing rejects it before any byte is held, so such a flow never waits.
  • recognise anything but TLS and HTTP/1.x. There is no QUIC, DNS or BitTorrent sniffer, and UDP flows are never collected (see Where sniffing runs).

The pieces are spread over four crates and katana:

Crate Piece Role
etemenanki-concepts concepts/src/sniff.rs The shared vocabulary: Sniffer, SniffedBehavior, SniffedProtocol. No parsing.
etemenanki-protocols protocols/src/sniff/ The two parsers, plausible_domain, worth_sniffing, the Collector, SNIFF_LIMIT and SNIFF_TIMEOUT.
etemenanki-protocols protocols/src/core/mod.rs and each server core SniffPrefix and Phase::Sniff: holding the bytes, arming the window, opening the flow.
etemenanki-environment environment/src/routing.rs RouteTarget::sniffed_domain, which every domain matcher consults.
etemenanki-app, katana app/src/router.rs, katana src/router.rs Copy Flow::sniffed into the RouteTarget.

concepts/src/sniff.rs defines what a sniffer produces. It lives in the concepts crate so that Flow (in the protocols crate) and katana’s router can name the result without depending on the parsers.

concepts/src/sniff.rs
pub enum SniffedProtocol {
Tls,
Http,
}
pub struct SniffedBehavior {
pub protocol: SniffedProtocol,
pub domain: CompactString,
}
pub trait Sniffer {
fn sniff(&self, data: &[u8]) -> Option<SniffedBehavior>;
}

Sniffer::sniff is a pure function of the bytes: no state, no clock, no allocation beyond the returned name. The collector calls it again on a longer buffer each time more bytes arrive and stops at the first Some, so an implementation should answer None for a prefix it cannot yet decide. The TLS parser keeps to this strictly. The HTTP parser does not wait for the end of the head, so it can answer from a Host line that is still arriving (see The HTTP request parser).

protocols/src/sniff/mod.rs holds the two limits and the three free functions every caller uses:

protocols/src/sniff/mod.rs
pub const SNIFF_TIMEOUT: Duration = Duration::from_millis(300);
pub const SNIFF_LIMIT: usize = 4 * 1024;
const MAX_DOMAIN_LEN: usize = 253;
pub fn plausible_domain(host: &str) -> Option<CompactString>;
pub fn sniff(data: &[u8]) -> Option<SniffedBehavior>;
pub fn worth_sniffing(destination: &Destination) -> bool;
Constant Value Why it must exist
SNIFF_TIMEOUT 300 ms In a server-speaks-first protocol (SMTP, SSH, MySQL) the client sends nothing until the server has spoken. Without a window the core would wait forever while holding the transport.
SNIFF_LIMIT 4096 bytes Without it a peer could stream an unbounded amount before being routed. The budget spans frames, because a ClientHello with a long extension list can exceed one read or one AEAD chunk.
MAX_DOMAIN_LEN 253 The longest legal DNS name. plausible_domain rejects anything longer.

sniff runs the implemented sniffers in a fixed order:

protocols/src/sniff/mod.rs
pub fn sniff(data: &[u8]) -> Option<SniffedBehavior> {
TlsSniffer.sniff(data).or_else(|| HttpSniffer.sniff(data))
}

TLS goes first because a record header is a much tighter discriminator than a request line. The two cannot both match the same bytes: the TLS sniffer requires a first byte of 0x16, and the HTTP sniffer requires the buffer to start with an upper-case method name.

worth_sniffing is the gate every caller checks before it enters the sniffing phase:

protocols/src/sniff/mod.rs
pub fn worth_sniffing(destination: &Destination) -> bool {
matches!(destination.remote, Remote::IpAddr(_))
}

A destination that already names a domain routes by that name, so waiting on it would cost every such flow up to SNIFF_TIMEOUT and buy nothing. The check looks at remote only, not at network: keeping UDP out is the job of the callers, which never enter the phase for a datagram request.

protocols/src/sniff/collector.rs is the clock-free half of sniffing: a bounded accumulator that re-runs sniff over everything it holds each time bytes are appended.

protocols/src/sniff/collector.rs
pub enum Verdict {
Found,
More,
Exhausted,
}
pub struct Collector {
buf: Vec<u8>,
found: Option<SniffedBehavior>,
}
impl Collector {
pub fn new() -> Self;
pub fn remaining(&self) -> usize;
pub fn push(&mut self, bytes: &[u8]) -> Verdict;
pub fn held(&self) -> &[u8];
pub fn found(&self) -> Option<&SniffedBehavior>;
pub fn take(self) -> (Vec<u8>, Option<SniffedBehavior>);
}
Method Behaviour
new Reserves the whole budget up front with Vec::with_capacity(SNIFF_LIMIT), so collecting never reallocates.
remaining SNIFF_LIMIT.saturating_sub(buf.len()).
push Appends all of bytes, then runs sniff(&buf) unless a result is already stored. Returns Found if a result exists, else Exhausted if buf.len() >= SNIFF_LIMIT, else More. Found wins: a match in the bytes that fill the budget is still Found.
held Everything collected so far. It is ordinary payload that the caller must forward to the outbound ahead of the rest of the stream.
found The stored result, by reference.
take Consumes the collector and returns the bytes and the result. The async SOCKS server uses it.

push does not truncate its input. The caller must offer at most remaining() bytes, and both callers do: SniffPrefix::push slices its input, and the SOCKS collect_prefix reads at most remaining() bytes per read.

Collector has no deadline. A sans-I/O core arms the runtime’s single timer itself through Timing, and the async SOCKS server wraps its reads in tokio::time::timeout_at. Keeping the clock out of Collector lets both kinds of caller share it and lets the unit tests drive it without a runtime.

Every sniffing sans-I/O core composes a SniffPrefix from protocols/src/core/mod.rs. It wraps a Collector and adapts it to the core contract, where handle returns how many bytes of an event it consumed.

protocols/src/core/mod.rs
pub struct SniffPrefix {
collector: Collector,
}
impl SniffPrefix {
pub fn new() -> Self;
pub fn push(&mut self, plain: &[u8]) -> (usize, Verdict);
pub fn held(&self) -> &[u8];
pub fn found(&self) -> Option<&SniffedBehavior>;
pub fn result(&self) -> Option<SniffedBehavior>;
pub fn clear(&mut self);
pub fn is_empty(&self) -> bool;
}
  • push takes min(plain.len(), collector.remaining()) bytes and returns that count with the verdict. With nothing to take, it answers Exhausted if the budget is spent and More otherwise (an empty input). A plaintext core returns the count as its consumed length, so bytes past the budget stay in the runtime’s read buffer and are relayed normally after the open.
  • result clones the found value and leaves the bytes in place, because they still have to be forwarded.
  • clear replaces the collector with Collector::new(). Cores call it at the top of every byte event once the flow is open; the first such call drops the prefix (see The held-buffer rule).

The same file defines the phase and deadline machinery that sniffing plugs into:

protocols/src/core/mod.rs
pub enum Phase {
Handshake,
Sniff,
Relay,
Closing,
}
pub enum Expired {
Handshake,
Sniff,
Idle,
}
impl Timing {
pub fn touch<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>);
pub fn enter<C: ProxyCoreDecode>(&mut self, phase: Phase, fx: &mut Effects<'_, C>);
pub fn expired<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>) -> Expired;
}
  • Timing::enter(Phase::Sniff, fx) pushes Effect::SetDeadline(Some(SNIFF_TIMEOUT)). The runtime has one timer, so this replaces the handshake deadline.
  • Timing::touch does nothing in Phase::Sniff. The window is therefore a fixed 300 ms from the moment the core starts sniffing, not an idle timer that each byte extends.
  • When Event::Deadline arrives in that phase, expired returns Expired::Sniff and pushes no effect. The core then opens the flow with whatever it holds.

The result travels on the flow itself, in protocols/src/flow.rs:

protocols/src/flow.rs
pub struct Flow<T> {
pub destination: Destination,
pub user: NetworkUser<T>,
pub sniffed: Option<SniffedBehavior>,
pub source: Option<IpAddr>,
}
impl<T> Flow<T> {
pub fn new(destination: Destination, user: NetworkUser<T>, source: Option<IpAddr>) -> Self;
pub fn toward(&self, destination: Destination) -> Self;
}

Flow::new starts with sniffed: None. A core sets the field just before it pushes Effect::Open, so the connector sees it when it routes. Flow::toward builds a flow for another destination that shares the user and source, and it resets sniffed to None; the hand-written Clone keeps it. See Flow::toward starts clean.

protocols/src/sniff/tls.rs → TlsSniffer walks the record header, the handshake header and the extension list to the server_name extension (RFC 8446 section 4.1.2, RFC 6066 section 3). It reads only what it needs and ignores every version field.

protocols/src/sniff/tls.rs
const RECORD_HANDSHAKE: u8 = 0x16;
const HANDSHAKE_CLIENT_HELLO: u8 = 0x01;
const EXT_SERVER_NAME: u16 = 0x0000;
const SNI_HOST_NAME: u8 = 0x00;
const AFTER_RANDOM: usize = 4 + 2 + 32;
pub struct TlsSniffer;
fn u16_at(b: &[u8], at: usize) -> Option<u16>;
fn client_hello_sni(data: &[u8]) -> Option<CompactString>;
fn server_name(ext: &[u8]) -> Option<CompactString>;

The record header is read from the whole buffer:

Field Size Offset What the parser does
Content type 1 0 Must be 0x16 (RECORD_HANDSHAKE), else None.
Legacy record version 2 1 Not read.
Record length 2 3 Advisory: body = data[5 .. min(5 + length, data.len())].

The rest of the walk works on body, so these offsets are into body:

Field Size Offset in body What the parser does
Handshake type 1 0 Must be 0x01 (HANDSHAKE_CLIENT_HELLO), else None.
Handshake length 3 1 Not read.
legacy_version 2 4 Not read.
random 32 6 Skipped. The walk starts at AFTER_RANDOM = 38.
legacy_session_id 1 + n 38 Length byte, then skipped.
cipher_suites 2 + n varies Big-endian length, then skipped.
legacy_compression_methods 1 + n varies Length byte, then skipped.
Extensions length 2 varies Sets ext_end = min(p + length, body.len()).
Extension type 2 per extension 0x0000 (EXT_SERVER_NAME) ends the walk.
Extension length 2 per extension The extension data must lie inside body, else None.

Inside the server_name extension:

Field Size What the parser does
server_name_list length 2 The list must lie inside the extension data, else None.
name_type 1 Only 0x00 (SNI_HOST_NAME) carries a domain. Other types are skipped.
Name length 2 The name must lie inside the list, else None.
HostName n Must be UTF-8, then must pass plausible_domain.
  1. Check the content type, read the record length and cut body. The record length is a ceiling, not a promise: while bytes are still arriving the buffer holds only a prefix of the record, the walk runs out of bytes and returns None, and the next Collector::push tries again with more.
  2. Check the handshake type and set p = AFTER_RANDOM.
  3. Skip the three variable-length vectors, each with p = p.checked_add(width)?.checked_add(length)?.
  4. Read the extensions length and compute ext_end, clamped to body.len().
  5. While p < ext_end, read the type and length, take body.get(at..at + len)?, and return server_name(edata) at the first extension of type 0x0000. Otherwise step past the extension.
  6. In server_name, walk the entries and return plausible_domain of the first host_name entry.

The parser stops at the first server_name extension and the first host_name entry, even when that entry fails plausible_domain. Both RFCs allow only one of each.

Because every length is checked against the bytes present, a truncated ClientHello can only produce None or the real name. A cut inside the SNI makes the server_name extension run past body, so the get for the extension data fails before the name is read. A cut at an extension boundary ends the loop at ext_end, which was clamped to body.len(), and also yields None.

No step can panic or read out of bounds, whatever the peer sends:

  • Every offset is computed with checked_add, so an overflowing length yields None.
  • Every read goes through slice::get or u16_at, which returns None past the end.
  • ? carries None out of the whole parse.

The crate-level lint set in protocols/src/lib.rs keeps it that way: #![deny(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::arithmetic_side_effects)], lifted only under cfg(test). The lints are deny, so an index expression or an unchecked + in a parser is a cargo clippy error, whatever warning level the run uses.

protocols/src/sniff/http.rs → HttpSniffer recovers the host of an HTTP/1.x request. Its first job is to refuse bytes that merely contain Host:.

protocols/src/sniff/http.rs
const METHODS: &[&str] = &[
"GET", "POST", "HEAD", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH",
];
pub struct HttpSniffer;
fn request_host(data: &[u8]) -> Option<CompactString>;
fn request_target(line: &str) -> Option<&str>;
fn authority_of(target: &str) -> Option<CompactString>;
fn strip_port(host: &str) -> Option<&str>;
  1. Cut the head. head_end is the position of the first \r\n\r\n, or the end of the buffer when the blank line has not arrived yet (the usual case while collecting). Only data[..head_end] is decoded, and it must be valid UTF-8. A body after the head is never decoded, so a binary body does not defeat the parse.
  2. Require a request line. The head is split on \r\n. request_target splits the first line on single spaces and requires a method from METHODS (case-sensitive), a request target, and a third field starting with HTTP/. Anything else yields None. The fixed method list is what keeps binary payloads from being read as HTTP, and it also means an HTTP/2 connection preface (PRI * HTTP/2.0) is not recognised.
  3. Prefer an absolute-form authority. If the target starts with http:// or https://, authority_of takes the text up to the first /, drops userinfo by keeping what follows the last @, strips the port and runs plausible_domain. A proxy request carries this form. When it yields a domain it wins over any Host header; when it does not, the parser falls through to the next step.
  4. Otherwise read Host. The first header line whose name (the text before the first :, not trimmed) equals host, ignoring ASCII case, supplies the value. Later Host lines are not consulted, even when the first one is rejected. The value is trimmed, strip_port removes a trailing : followed only by digits, and plausible_domain decides.

strip_port leaves a value that starts with [ untouched, so a bracketed IPv6 literal reaches plausible_domain whole and is rejected there for its characters.

Both parsers end in the same filter, which decides whether a string can be used as a routing domain:

protocols/src/sniff/mod.rs
pub fn plausible_domain(host: &str) -> Option<CompactString>;
Step Rule Rejects
1 Trim trailing . characters. Nothing: example.com. becomes example.com.
2 Length must be 1 to MAX_DOMAIN_LEN (253). "", ".", a 254-character name
3 Every character is ASCII alphanumeric, -, . or _. Spaces, :, [, ], /, ?, non-ASCII, and therefore every IPv6 literal such as ::1
4 Must not parse as IpAddr. IPv4 literals such as 192.0.2.1

An IP literal is rejected on purpose. It tells the router nothing the destination did not already say, and accepting it would let a client label a flow with an address it is not talking to. Case is preserved here; the route model lowercases the name when it matches (see How the result is used).

The inbound’s sniff flag reaches each core’s constructor:

  • etemenanki-app: the per-inbound sniffing key (app/src/config.rs → default_sniffing, default true). app/src/inbound/mod.rs copies it into StreamInbound::sniff, the SOCKS and Hysteria constructors, and app/src/serve.rs passes it to every stream core. For tun, app/src/inbound/tun.rs calls TunInbound::without_sniffing when it is false.
  • katana: the negation of the node’s disable_sniffing key under [node.controller] (src/manager/node.rs), read by NodeManager::bring_up when it builds the listener, held by the node’s proxy manager and passed to every core src/serve.rs builds and to build_hysteria in src/inbound.rs. A hot reload that changes disable_sniffing counts as a local listener edit: NodeManager::apply_static forces a new listener generation at once, which drops the node’s open connections. A node whose listener is still coming up builds from the new value on its next attempt. See How a node applies a static update.
protocols/src/http/core.rs
pub fn new(config: Arc<HttpServerConfig<T>>, sniff: bool, source: Option<IpAddr>) -> Self;
// protocols/src/socks/server.rs
pub fn new(config: SocksServerConfig<T>, sniff: bool) -> Self;
// protocols/src/hysteria/server/inbound.rs (Hy2StreamCore; the flag comes from the listener config)
pub fn new(user: UserEntry<T>, sniff: bool, source: IpAddr) -> Self;
// protocols/src/trojan/core.rs and protocols/src/vless/core.rs
pub fn new(validator: Arc<Validator<T>>, sniff: bool, source: Option<IpAddr>) -> Self;
// protocols/src/vmess/core.rs
pub fn new(
validator: Arc<AccountValidator<T>>,
now: fn() -> i64,
sniff: bool,
source: Option<IpAddr>,
) -> Self;
// protocols/src/ss_legacy/core.rs
pub fn new(inner: Arc<Resolved<T>>, sniff: bool, source: Option<IpAddr>) -> Self;
// protocols/src/ss_2022/core.rs
pub fn with_system_clock(
config: Arc<Ss2022ServerConfig<T>>,
validator: Option<Arc<Validator<T>>>,
sniff: bool,
source: Option<IpAddr>,
) -> Self;
// protocols/src/core/mod.rs (PassthroughCore, used by TUN)
pub fn sniffing(flow: Flow<T>) -> Self;
// protocols/src/mux/demux.rs
pub fn new(flow: Flow<T>, sniff: bool) -> Self;

A caller sniffs only when the flag is set and worth_sniffing(&flow.destination) is true, and only for the request kinds in this table:

Inbound Where Sniffed requests Bytes inspected Reply before sniffing
TUN PassthroughCore::sniffing TCP connections First transport bytes No reply exists
HTTP HttpCore CONNECT only Tunnel payload Yes, 200 Connection established
SOCKS 4, 4a, 5 SocksInbound::connect CONNECT only Tunnel payload Yes, the grant
Hysteria 2 Hy2StreamCore TCP streams Stream payload Yes, a TCPResponse “Connected”
Trojan TrojanCore CONNECT, except to the mux address Payload after the header No
VLESS VlessCore Command::Tcp Payload after the header No
VMess VMessCore Command::Tcp Decrypted body chunks No
Shadowsocks ShadowsocksCore Every request (TCP) Decrypted chunks No
Shadowsocks 2022 Ss2022Core Every request (TCP) Decrypted records No
mux.cool Demux Every New to an IP The New frame’s payload only No

A plain (non-CONNECT) HTTP proxy request is never sniffed: HttpCore opens it as soon as its head is parsed. UDP requests (SOCKS UDP ASSOCIATE, Trojan, VLESS and VMess UDP, the Hysteria datagram core, TUN UDP) are not collected either.

A SOCKS UDP ASSOCIATE is served by SocksInbound::associate, which never reads the sniff flag. It opens its one link with Flow::new(dest, user, source) for the first datagram it forwards, so sniffed stays None, and it forwards only datagrams from the client its ExpectedSender admits (over TCP, the control connection’s IP, pinned to one port). Whom an association hears covers that check.

Every single-stream core that sniffs has the same shape, whatever its protocol calls the states: State::Sniff(Flow<T>) in the HTTP, Hysteria, Trojan and VLESS cores; State::Sniff with the flow stored beside it in VMess and both Shadowsocks cores; pending plus Phase::Sniff in PassthroughCore.

stateDiagram-v2
  [*] --> Handshake
  Handshake --> Relay: parsed, domain target or sniff off
  Handshake --> Sniff: parsed, IP target and sniff on
  Sniff --> Sniff: push returns More
  Sniff --> Relay: Found or Exhausted
  Sniff --> Relay: Event Deadline, Expired Sniff
  Sniff --> Relay: client EOF, then half-close
  Relay --> Closing
  Closing --> [*]

Every exit from Sniff goes through the core’s open path (open_sniffed, or open in PassthroughCore), which does three things in order:

  1. flow.sniffed = self.prefix.result(), which is Some only after Found.
  2. Push Effect::Open with the flow, then Effect::ForwardHeld { range: 0..held } when anything was collected.
  3. timing.enter(Phase::Relay, fx), which re-arms the one deadline as the RELAY_IDLE_TIMEOUT idle limit.

PassthroughCore has no header to parse. It enters Phase::Sniff on its first transport event, so its window starts with the client’s first bytes.

flowchart TB
  ev["Event::Transport(data)"] --> push["SniffPrefix::push(plain)"]
  push --> take["take = min(len, remaining)"]
  take --> sniffers["sniff(held): TLS, then HTTP"]
  sniffers --> v{"Verdict"}
  v -->|More| ret["return Ok(take), wait for the next event"]
  v -->|"Found or Exhausted"| open["open_sniffed"]
  dl["Event::Deadline"] --> open
  eof["Event::TransportEof"] --> open
  open --> fx["Effect::Open, then Effect::ForwardHeld 0..held"]
  fx --> relay["Phase::Relay"]

The cores fall into two groups by how their payload arrives:

  • Plaintext payload (HTTP, Hysteria, Trojan, VLESS, TUN). The core pushes the event slice directly and returns the count SniffPrefix::push took. The HTTP, Hysteria, Trojan and VLESS cores return after the request, so the payload that followed it in the same read reaches the Sniff state on the runtime’s next call. The runtime keeps calling while the core makes progress, so bytes past the budget are relayed normally after the open.
  • Decrypted payload (VMess, Shadowsocks, Shadowsocks 2022). The core opens a chunk or record in place, pushes its plaintext range, and consumes the chunk whole. When the verdict ends collection partway through a chunk, the core pushes an Effect::Forward for the plaintext after the bytes it took. VMess keeps going and forwards any later chunks of the same event by range; the two Shadowsocks cores return after that chunk and relay the rest on the next call.

The Shadowsocks cores also receive payload inside the request itself: the legacy core’s first chunks carry the address and then payload, and a Shadowsocks 2022 variable header carries a first payload. Both push that payload before deciding, so a request whose own payload already answers Found or Exhausted opens at once without entering Phase::Sniff. The legacy core has one extra case: if the budget runs out inside that payload, it copies the prefix and the rest of the payload into its own held buffer so that one ForwardHeld carries both.

A sniffing core consumes the collected bytes from the wire and keeps them in SniffPrefix. It exposes them through ProxyCoreDecode::held and forwards them by range with Effect::ForwardHeld, which the runtime resolves against held() when it applies the effect. That happens after the connector has dialled, not when the effect is pushed.

The rule that makes this safe is in concepts/src/core.rs (module docs, “The held buffer”): until every queued held effect has been applied, the runtime delivers the core no byte event (Transport, TransportDatagram, Outbound, Datagram, OutboundEof, TransportEof). The events that can still arrive meanwhile (Connected, ConnectFailed, OutboundError, Deadline) may only append to the held buffer. A core can therefore call SniffPrefix::clear at the top of its next byte event in Relay, and every sniffing core does. Server runtime covers the runtime side.

Three kinds of client send nothing after their request until the server has answered. Sniffing needs payload, so these servers answer success first, then collect, then connect:

Protocol What the client waits for Where the early answer is written
HTTP CONNECT 200 Connection established HttpCore::on_head stages CONNECT_ESTABLISHED
SOCKS 4, 4a and 5 CONNECT The grant SocksInbound::connect calls write_granted
Hysteria 2 TCP stream The TCPResponse Hy2StreamCore stages response(true, "Connected")

The other protocols let the client send payload right behind the request header, so they collect first and answer (where the protocol has an answer at all) after the connect.

sequenceDiagram
  participant C as Client
  participant S as Server core
  participant R as Runtime
  participant N as Connector
  participant T as Target 192.0.2.10
  C->>S: CONNECT 192.0.2.10:443
  S->>R: stage success, SetDeadline(SNIFF_TIMEOUT)
  R->>C: success answer
  C->>S: ClientHello
  S->>S: SniffPrefix::push returns Found
  S->>R: Open (sniffed = example.com), ForwardHeld 0..n
  R->>N: connect(flow)
  N->>N: route_target with the sniffed domain
  N->>T: dial 192.0.2.10:443
  R->>S: Event::Connected
  Note over S: reply is false, no second answer
  R->>T: held ClientHello

The diagram shows the sans-I/O cores. HttpCore and Hy2StreamCore open with reply: false, so Event::Connected stages nothing. Both declare a STAGING_RESERVE large enough for the early answer: 256 bytes for HTTP, and 2048 for a Hysteria TCPResponse with the longest padding. If staging still fails, the core returns staging_full().

SOCKS is not a sans-I/O core. SocksInbound::connect in protocols/src/socks/server.rs follows the same order with async I/O:

protocols/src/socks/server.rs
async fn collect_prefix<S: AsyncRead + Unpin>(
stream: &mut S,
) -> (Vec<u8>, Option<etemenanki_concepts::sniff::SniffedBehavior>);
  1. write_granted sends the success reply.
  2. collect_prefix creates a Collector, fixes one deadline SNIFF_TIMEOUT ahead, and loops. Each pass reads at most remaining() bytes under tokio::time::timeout_at(deadline, …). The loop stops on a verdict other than More, on EOF, on a read error, at the deadline, or when no room is left. It returns Collector::take().
  3. connector.connect(flow) routes with flow.sniffed set.
  4. The prefix is written to the upstream with write_all, and relay_with_idle_guard relays both directions.

After the connect, connect chooses between the sniffing and the plain path on prefix.is_empty(), not on whether the grant was already sent. When sniffing ran but collected nothing (the client sent nothing within SNIFF_TIMEOUT, closed its write side, or the read failed), connect takes the plain path as well: on success it writes a second grant, which the client receives as the first bytes of the tunnel, and on failure it writes a refusal after the grant. A server-speaks-first protocol (SMTP, MySQL) carried through a SOCKS inbound to an IP target with sniffing on therefore sees an extra SOCKS reply ahead of the server’s first bytes. Turning sniffing off on that inbound avoids it.

protocols/src/mux/demux.rs → Demux serves mux.cool inside Trojan, VLESS and VMess. The carrier core passes its own sniff flag to Demux::new(flow, sniff).

A sub-flow is opened the moment its New frame is parsed, and the carrier cannot stop to collect bytes for one sub-flow without stalling all the others. So a sub-flow is sniffed once, from whatever payload its New frame carried, with no Collector, no SNIFF_LIMIT and no deadline:

protocols/src/mux/demux.rs
let mut flow = self.flow.toward(target.clone());
if self.sniff && crate::sniff::worth_sniffing(&target) {
flow.sniffed = payload.and_then(crate::sniff::sniff);
}

A New without data, or one whose data ends before the SNI or the Host line, gives the sub-flow no sniffed name. The carrier itself is never sniffed: Trojan checks is_mux_destination before it considers sniffing, and VLESS and VMess switch to State::Mux on Command::Mux.

The check is worth_sniffing(&target) alone, so a UDP sub-flow’s first packet is offered to the sniffers too. The result is never read for UDP; see the next section.

One function in each product reads Flow::sniffed, and only to build a route target:

app/src/router.rs
pub fn route_target<'a>(flow: &'a Flow, ctx: &'a FlowContext) -> routing::RouteTarget<'a>;
katana src/router.rs
pub fn route_target<'a>(
dest: &'a Destination,
sniffed: Option<&'a SniffedBehavior>,
source: Option<IpAddr>,
) -> routing::RouteTarget<'a>;

AppConnector::connect in app/src/connector.rs calls the first; katana’s src/connector.rs calls the second with flow.sniffed.as_ref(). Both call RouteTarget::with_sniffed_domain(s.domain.as_str()).

In environment/src/routing.rs the private Domains view holds the request’s own domain (when the destination names one) and the sniffed domain, both lowercased with to_ascii_lowercase. Every domain matcher (DomainSuffix, DomainKeyword, DomainFull, DomainRegex, GeoSite) goes through Domains::any and matches if either name satisfies it. Cidr, GeoIp, SourceCidr, PortRange, Network and InboundTag ignore the sniffed name, and no matcher reads SniffedBehavior::protocol. The first-match order of the table is unchanged. Route model covers the matchers.

After routing, AppConnector::connect hands the unchanged flow to outbound.connect_stream(flow), and the outbound dials flow.destination: still the IP the client asked for. katana’s connector does the same with outbound.connect_stream(&flow.destination). No outbound client reads sniffed. The sniffed name changes which rule matches, never where the bytes go. The app end-to-end tests rely on this: the echo server they reach listens on 127.0.0.1, and the sniffed name sniffed.example does not resolve.

Flow::toward copies the user and source to a new destination and sets sniffed: None. It is used wherever one flow fans out to others:

Caller New flow Why a clean sniffed is right
Demux, on New A mux sub-flow The carrier’s value says nothing about the sub-flow. The sub-flow’s own value is set from its New payload right after.
TrojanCore, UDP The association’s first packet A UDP flow is not sniffed.
app/src/outbound/udp_fanout.rs → FanOutLink::poll_send_to One flow per datagram, to route it and to open its outbound Each packet has its own destination, and a name recovered for one target must not route packets to another.

AppConnector::connect turns every UDP flow into a FanOutLink, which routes each packet with route_target(&self.flow.toward(to.clone()), &self.ctx). That is why a sniffed value on a UDP mux sub-flow is never read, and why a UDP packet addressed to an IP matches only non-domain rules. katana’s UDP FanOut calls its route_target with None for the same reason.

Invariant Enforced by Pinned by
Sniffing never fails a connection. Every sniffer returns Option, and every exit from the sniffing state goes through open_sniffed. non_tls_and_malformed_input_yield_nothing (protocols/tests/unit/sniff/tls.rs), an_unsniffable_payload_falls_through_to_the_default (app/tests/integration/e2e_sniff.rs)
The parsers cannot panic or read out of bounds. checked_add, slice::get and u16_at; the crate’s deny(clippy::indexing_slicing, clippy::arithmetic_side_effects, clippy::unwrap_used, clippy::expect_used). non_tls_and_malformed_input_yield_nothing, a_truncated_client_hello_yields_nothing_rather_than_garbage
A truncated ClientHello yields the real SNI or nothing. Every length is checked against the bytes present; a short read ends in None. a_truncated_client_hello_yields_nothing_rather_than_garbage
A request split across reads is re-examined as a whole, and every byte is kept. (A Host value cut mid-name is the exception described under the HTTP parser.) Collector::push re-runs sniff over the whole buffer. a_host_split_across_pushes_is_found_once_complete, sniff_prefix_finds_a_host_across_pushes_and_keeps_the_bytes (both split inside the header name)
No more than SNIFF_LIMIT bytes are held. SniffPrefix::push takes min(len, remaining()); collect_prefix reads at most remaining(). sniff_prefix_takes_no_more_than_its_budget (protocols/tests/unit/core/mod.rs), the_budget_ends_the_search_without_a_match
No flow waits longer than SNIFF_TIMEOUT for a name. Timing::enter(Phase::Sniff) arms the deadline and touch never extends it; timeout_at against one fixed deadline in SOCKS. timing_reports_handshake_and_sniff_expiry_to_the_core, a_sniffing_passthrough_core_opens_on_the_sniff_deadline, sniff_deadline_opens_with_what_was_collected (protocols/tests/unit/trojan/core.rs)
A flow that names a domain is never delayed. worth_sniffing is checked before entering the phase. a_domain_target_is_never_sniffed, a_sniffing_passthrough_core_with_a_domain_target_opens_at_once
Collected bytes reach the outbound in order, ahead of later payload. Effect::ForwardHeld is pushed right after Effect::Open; the runtime’s held-buffer rule. held_bytes_are_forwarded_after_the_dial_and_survive_later_rewrites (concepts/tests/runtime.rs), new_server_vs_new_client_tcp (protocols/tests/pipeline/socks.rs)
An IP literal is never a sniffed name. plausible_domain rejects anything that parses as IpAddr. domain_plausibility, an_ip_literal_sni_is_rejected, an_ip_host_is_rejected
Bytes that merely contain Host: are not HTTP. request_target requires a listed method and an HTTP/ version. bytes_that_merely_contain_a_host_header_are_not_http
An early-answer core answers once. open(flow, false, fx) after the early answer, so Connected stages nothing. connect_to_an_ip_with_sniffing_replies_early_and_holds_the_prefix (protocols/tests/unit/http/core.rs), sniffing_an_ip_target_answers_early_and_opens_with_the_host (protocols/tests/unit/hysteria/server/inbound.rs)
sniffing = false stops the inspection itself. The flag gates entry into the phase, so no bytes are held and no window is armed. turning_sniffing_off_stops_the_domain_rule_matching checks the outcome (the domain rule no longer matches); no test observes that the wait is gone.
The sniffed name only adds a domain to match. Domains::any in the route model; outbounds dial flow.destination. a_sniffed_domain_makes_an_ip_target_match_domain_rules, a_sniffed_domain_feeds_geosite_too (environment/tests/unit/routing.rs), a_tcp_connection_becomes_a_stream (protocols/tests/pipeline/tun.rs)
  • Unrecognised or malformed bytes: the collector answers More until the budget or the window ends. The flow then opens with sniffed: None, and the collected bytes are forwarded unchanged.
  • Deadline: Event::Deadline in Phase::Sniff maps to Expired::Sniff, and the core calls open_sniffed. The handshake deadline no longer applies once the request is parsed, because enter replaced it.
  • Client EOF while sniffing: the sans-I/O cores handle Event::TransportEof (and, in VMess and legacy Shadowsocks, the chunk stream’s terminator) in the sniffing state by calling open_sniffed first and then Passthrough::on_transport_eof. The outbound is still dialled, receives what was collected, and then sees the half-close. In SOCKS a zero-byte read or a read error ends collect_prefix early, and the connect goes ahead with what was read.
  • Connect failure after an early answer: see the caution above. Without an early answer, each protocol reports a failed connect as it normally does.
  • Staging failure: fx.stage(...).ok_or_else(staging_full)? returns io::Error::other("staging room below the core's declared reserve"). It cannot happen while the core’s STAGING_RESERVE covers the early answer.
  • Cancellation: sniffing owns no task, channel or lock. In a sans-I/O core the collected bytes live in the core and are dropped with it. In SOCKS, dropping the serve future drops the in-flight timeout_at read and the Collector.
Name Value Where Meaning
SNIFF_TIMEOUT 300 ms protocols/src/sniff/mod.rs Longest wait for a recognisable prefix, from entering the phase.
SNIFF_LIMIT 4096 bytes protocols/src/sniff/mod.rs Most bytes inspected and held per flow, across frames.
MAX_DOMAIN_LEN 253 protocols/src/sniff/mod.rs Longest accepted name, after trimming trailing dots.
AFTER_RANDOM 38 protocols/src/sniff/tls.rs Offset of legacy_session_id inside the handshake body.
HANDSHAKE_TIMEOUT 10 s protocols/src/core/mod.rs Applies before sniffing, while the request is parsed.
RELAY_IDLE_TIMEOUT 300 s protocols/src/core/mod.rs Armed when the flow opens.

Behaviour worth knowing when you change these:

  • A flow whose first message is complete but carries no name (a ClientHello without SNI, an HTTP request without Host) waits the full SNIFF_TIMEOUT, because neither sniffer can answer “definitely not” and the collector keeps asking for more. The same wait applies to server-speaks-first protocols and to any other client-first protocol that sends less than SNIFF_LIMIT bytes before it waits for an answer. Turning sniffing off, or letting the client send a domain, removes the wait.
  • SniffPrefix::new reserves SNIFF_LIMIT bytes, and every sniffing-capable core constructs one, so each such connection carries that reservation whether or not it sniffs. clear builds a fresh Collector, which reserves the same amount again, and the cores call it on every relay byte event, so the reservation lasts for the whole connection.

Run cargo test -p etemenanki-protocols sniff for the parsers and the collector, and the whole crate for the cores. The app end-to-end tests drive a real Xray client and return early, passing, when no Xray binary is available.

File Tests What they pin
protocols/tests/unit/sniff/mod.rs domain_plausibility Trailing dot, empty, ., a space, 254 characters, IPv4 and IPv6 literals.
protocols/tests/unit/sniff/collector.rs a_host_split_across_pushes_is_found_once_complete, the_budget_ends_the_search_without_a_match More then Found across pushes with the full prefix kept; Exhausted at exactly SNIFF_LIMIT.
protocols/tests/unit/sniff/tls.rs extracts_sni_from_a_real_client_hello, a_truncated_client_hello_yields_nothing_rather_than_garbage, an_ip_literal_sni_is_rejected, non_tls_and_malformed_input_yield_nothing, hand_built_and_real_hellos_agree The parser against a ClientHello produced by OpenSSL, truncations at several cuts, a hand-built hello with an IP SNI, and lengths that run past the buffer.
protocols/tests/unit/sniff/http.rs extracts_the_host_header, host_header_port_and_case_are_normalised_away, absolute_form_authority_wins_over_the_host_header, userinfo_is_stripped_from_an_absolute_target, bytes_that_merely_contain_a_host_header_are_not_http, an_ip_host_is_rejected, a_request_with_no_host_at_all_yields_nothing, a_binary_body_does_not_defeat_header_parsing Every rule of the HTTP parser.
protocols/tests/unit/core/mod.rs timing_reports_handshake_and_sniff_expiry_to_the_core, sniff_prefix_finds_a_host_across_pushes_and_keeps_the_bytes, sniff_prefix_takes_no_more_than_its_budget, a_sniffing_passthrough_core_holds_the_prefix_and_opens_with_the_host, a_sniffing_passthrough_core_opens_on_the_sniff_deadline, a_sniffing_passthrough_core_with_a_domain_target_opens_at_once The Phase::Sniff deadline, the SniffPrefix budget and clear, and the exact effect order Open, ForwardHeld, SetDeadline(RELAY_IDLE_TIMEOUT).
protocols/tests/unit/http/core.rs connect_to_an_ip_with_sniffing_replies_early_and_holds_the_prefix The early 200, the sniff window, and no second 200 on Connected.
protocols/tests/unit/hysteria/server/inbound.rs sniffing_an_ip_target_answers_early_and_opens_with_the_host The early “Connected” TCPResponse and no second response.
protocols/tests/unit/trojan/core.rs sniffing_an_ip_target_holds_the_prefix_until_a_host_is_found, sniff_deadline_opens_with_what_was_collected, a_domain_target_is_never_sniffed Entry condition, deadline and effect sequence.
protocols/tests/unit/vless/core.rs, vmess/core.rs, ss_legacy/core.rs, ss_2022/core.rs sniffing_holds_the_prefix_then_opens_with_the_domain, sniffing_reads_chunks_until_a_host_appears, sniffing_an_ip_target_waits_for_a_recognisable_prefix, sniffing_reads_records_until_a_host_appears Collection across plaintext events, encrypted chunks and records.
protocols/tests/pipeline/socks.rs new_server_vs_new_client_tcp, new_server_refuses_an_unreachable_target_after_trying With sniffing on and an IP target, 100 000 bytes round-trip intact, so the prefix is forwarded ahead of the relay; with sniffing off, the refusal reaches the client.
protocols/tests/pipeline/tun.rs a_tcp_connection_becomes_a_stream A TUN TCP flow to an IP carries sniffed = example.com from its Host header, and the request bytes reach the outbound unchanged.
concepts/tests/runtime.rs held_bytes_are_forwarded_after_the_dial_and_survive_later_rewrites, a_held_range_past_the_buffer_is_rejected The runtime side of the held-buffer rule.
environment/tests/unit/routing.rs a_sniffed_domain_makes_an_ip_target_match_domain_rules, a_sniffed_domain_feeds_geosite_too Domain and geosite matchers consult the sniffed name.
app/tests/integration/e2e_sniff.rs an_http_host_routes_an_ip_addressed_flow, a_tls_sni_routes_an_ip_addressed_flow, a_flow_whose_sniffed_host_does_not_match_is_blocked, an_unsniffable_payload_falls_through_to_the_default, turning_sniffing_off_stops_the_domain_rule_matching End to end through a VLESS inbound: only a rule written in domains leads to freedom, so bytes coming back prove the sniffed name routed the flow.

Demux’s sniffing of the New payload and the reset in Flow::toward have no dedicated test. A change to either should come with one.