Sniffing (Traffic Detection)
This page separates sniffing (sniffer / sniffing) from the routing document and explains:
- Where sniffing happens in the core and what enables it (code paths)
- How sniffing results affect routing and destination override (
destOverride/routeOnly/attrs) - Per-protocol sniffing flow and implementation logic (explained by code)
The configuration entry is still the inbound
sniffingfield. See Inbounds.
Where Sniffing Happens (Code Paths)
Sniffing happens before routing. The dispatcher reads a small amount of initial data and tries to identify the traffic:
- Entry points:
app/dispatcher/default_dispatch.go(*DefaultDispatcher).DispatchLink(...)(*DefaultDispatcher).Dispatch(...)
- Read & cache without consuming traffic:
cachedReader(app/dispatcher/default_cached_reader.go) - Build sniffer list:
NewSniffer(ctx)(app/dispatcher/sniffer.go) - Sniffing loop and timeout:
sniffer(...)(app/dispatcher/default_sniffing.go)
The result is written into session.Content so routing rules can read it (routing.rules.protocol, routing.rules.attrs, etc.).
When Sniffing Runs (Enablement)
Sniffing is not unconditional; it is triggered by two kinds of conditions:
Inbound sniffing is enabled
- When inbound
sniffing.enabled=true, the connection will be sniffed. - Only in this mode, the core is allowed to override the destination (
destOverride/routeOnly).
- When inbound
Route-driven sniffing (protocol-only)
- Even when
sniffing.enabled=false, the core may sniff in order to satisfyrouting.rules.protocol. - Protocols currently supporting route-driven sniffing:
wireguard/mtproto/zerotier/ech/rdp/mqtt/ntp/postgres/ikev2/dtls/trojan - Code:
DefaultDispatcher.shouldEnableProtocolSniffing→Router.ShouldSniffProtocolForContext(app/dispatcher/default_sniffing.go,app/router/router.go)
- Even when
Special Gating: DNS / ECH
dns: DNS sniffers are only added when all of the following are true (app/dispatcher/sniffer.go)- Inbound
sniffing.enabled=true - Routing rules require
protocol: "dns"(evaluated for the current routing context)
- Inbound
ech: ECH sniffing is only enabled when routing rules requireprotocol: "ech"; it is part of route-driven sniffing.
Special Gating: Trojan
Trojan sniffing is route-driven: it is only enabled when routing rules require protocol: "trojan" (evaluated for the current routing context).
Trojan sniffing is a conservative TLS ClientHello heuristic and does not extract a domain. The core combines it with the TLS sniff result (CompositeResult) so that domain routing / destination override keeps working.
metadataOnly
When inbound sniffing.metadataOnly=true, the core only runs metadata sniffers and will not read payload bytes for protocol detection.
Today this is mainly used for FakeDNS reverse lookup (see “FakeDNS” below).
Timeout and Retries (Why Sniffing Can Fail)
Sniffing only reads a small amount of early traffic and will not wait forever:
- Initial cache window: 200ms (
cacheDeadlineinsniffer()) - For
common.ErrNoClue, at most 2 attempts - For
protocol.ErrProtoNeedMoreData, the dispatcher keeps reading more data (without consuming attempts) until timeout
Implications:
- Some protocols may require more bytes / packets (TLS fragmented ClientHello, QUIC multi-packet CRYPTO, DNS-over-TCP length prefix, etc.)
- If the client doesn’t send enough data within 200ms, you may see
timeout on sniffing
How Sniffing Affects Routing and Destination
SniffResult: Output Fields
Sniffers return a SniffResult (app/dispatcher/sniffer.go):
Protocol() string: traffic “protocol type”, used byrouting.rules.protocolDomain() string: extracted domain (may be empty), used bydestOverride/routeOnly
Some results also implement:
ALPN() string: used to setattrs["alpn"], enablingalpn:*matching in routing (see Routing)
attrs: Attributes for Routing
During sniffing the core may write attributes to session.Content.Attributes (common/session/session.go, app/dispatcher/default_sniffing.go):
alpn: fromhttp/tls/quic/ech(if available)tls_sni: written when TLS sniffing succeeds;1means SNI exists,0means no SNI- HTTP/1.x headers: HTTP sniffing writes request headers into attrs (keys are lowercased) and also writes
:methodand:path
destOverride / routeOnly
Destination processing lives in app/dispatcher/default_dispatch.go / app/dispatcher/default_sniffing.go:
- Destination override only happens when inbound
sniffing.enabled=trueand the sniffed protocol matchesdestOverride(DefaultDispatcher.shouldOverride) - With
routeOnly=true, the sniffed domain is only written toRouteTargetfor routing; the actual outbound dial target stays as the original IP (Targetunchanged) - DNS sniffing is treated specially: it only affects
RouteTarget, to avoid dialing the queried domain as an actual destination
Supported Sniffed Protocols
Grouped by whether they can extract a domain:
- Domain extractable:
http(http1),tls,quic,dns,fakedns - Protocol-only identification (for
routing.rules.protocol):http2,ech,wireguard,mtproto,zerotier,rdp,mqtt,ntp,postgres(postgres+ssl/postgres+gssenc/postgres+cancel),ikev2,dtls,bittorrent(includes uTP),stun/turn,socks(socks4/socks5),ssh,trojan
dns/ech/ route-driven protocols have extra enablement conditions; see “When Sniffing Runs”.
Per-Protocol Sniffing Flow and Code Logic
Below are the entry points, enablement, and detection logic per protocol. Paths refer to Xray-core source.
HTTP (http1 / http2)
- Entry:
common/protocol/http/sniff.go→SniffHTTP - Network: TCP
- Detection:
- HTTP/2: match the connection preface
PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n→Protocol=http2- Protocol/ALPN only (
h2), no domain extraction
- Protocol/ALPN only (
- HTTP/1.x:
- ensure it starts with a common method (GET/POST/HEAD/PUT/DELETE/OPTIONS/CONNECT)
- split headers, parse
Host:viaParseHost→Domain - return
Protocol=http1,Domain=<host>
- HTTP/2: match the connection preface
- attrs:
- when
Content.Attributesis empty, headers are written (lowercased keys) plus:methodand:path
- when
TLS
- Entry:
common/protocol/tls/sniff.go→SniffTLS/ReadClientHello - Network: TCP
- Detection:
- validate TLS record (Handshake 0x16, version major=3)
- parse ClientHello extensions:
- SNI (ext 0x00) →
Domain - ALPN (ext 0x10) →
ALPN
- SNI (ext 0x00) →
- supports ClientHello fragmented across multiple TLS records (up to 8 records)
protocol.ErrProtoNeedMoreData:- returned when it is already determined to be a ClientHello but more bytes are required
- Output:
Protocol=tlsDomainmay be empty (no SNI)ALPNis the first entry in the list
- attrs:
- dispatcher writes
attrs["alpn"](if non-empty) - dispatcher writes
attrs["tls_sni"](1/0)
- dispatcher writes
QUIC
- Entry:
common/protocol/quic/sniff.go→SniffQUIC - Network: UDP
- Detection (high level):
- parse QUIC Long Header Initial (draft-29 and v1)
- derive initial secrets (RFC9001), remove header protection, decrypt Initial payload
- parse frames and collect CRYPTO frame data (reassembled by offset)
- parse TLS ClientHello from CRYPTO data (reuses
common/protocol/tls.ReadClientHello) to extract SNI/ALPN
- Multi-packet:
- if CRYPTO data is insufficient, continue parsing subsequent payload bytes
- if all payloads are valid QUIC packets but still insufficient, return
protocol.ErrProtoNeedMoreData
- Output:
Protocol=quic,Domain=<sni>,ALPN=<alpn>
DNS
- Entry:
common/protocol/dns/sniff.go→SniffDNS - Network: TCP / UDP
- Enablement (important):
- only enabled when routing requires
protocol:\"dns\"and inboundsniffing.enabled=true
- only enabled when routing requires
- Detection:
- UDP: parse the first DNS question name (lowercased, trailing dot removed)
- TCP: read 2-byte length prefix, then parse the DNS payload
protocol.ErrProtoNeedMoreData:- returned for incomplete DNS-over-TCP prefix/payload
- Output:
Protocol=dns,Domain=<qname> - Destination handling:
- only affects
RouteTarget(for routing), not the actual dial target
- only affects
FakeDNS (metadata sniffer)
- Entry:
app/dispatcher/fakednssniffer.gonewFakeDNSSniffer(metadata sniffer)newFakeDNSThenOthers(best-effort recovery on FakeDNS miss)
- Type: metadata sniffer (no payload reads)
- Enablement:
- FakeDNS is enabled and
dns.FakeDNSEngineis available in context
- FakeDNS is enabled and
- Detection:
- reverse lookup via
FakeDNSEngine.GetDomainFromFakeDNS(ip)using currentOutbound.Target.Address - on hit:
Protocol=fakedns,Domain=<domain>
- reverse lookup via
fakedns+others:- if IP is in FakeDNS pool but reverse lookup misses, try other sniffers to extract a domain
- returns
Protocol=fakedns+othersand keeps the original sniffer protocol for override matching
ECH
- Entry:
common/protocol/ech/sniff.go→SniffECH - Network: TCP
- Enablement:
- only enabled when routing requires
protocol:\"ech\"
- only enabled when routing requires
- Detection:
- parse TLS ClientHello (first record) and check for ECH extension
0xfe0d - also extracts ALPN when possible
- parse TLS ClientHello (first record) and check for ECH extension
- Output:
Protocol=echDomainis always empty (to avoid destination override based on Outer SNI)
WireGuard
- Entry:
common/protocol/wireguard/sniff.go→SniffWireGuard - Network: UDP
- Route-driven: supported
- Detection:
- WireGuard message type is little-endian uint32, with the upper 3 bytes zero
- validate type and expected/min length
- Output:
Protocol=wireguard
MTProto
- Entry:
app/dispatcher/mtprotosniffer.go→sniffMTProto - Network: TCP
- Route-driven: supported
- Detection (plaintext transports only):
- Abridged: first byte
0xef, then validate length field (including0x7f + 3-byteextended length) - Intermediate: first 4 bytes
0xeeeeeeee, then little-endian length - Padded Intermediate: first 4 bytes
0xdddddddd, then little-endian length - Packet length must follow MTProto 4-byte alignment rules
- Abridged: first byte
- Output:
Protocol=mtproto - Note: does not attempt to identify MTProxy obfuscated/fake-tls traffic
ZeroTier
- Entry:
app/dispatcher/zerotiersniffer.go→sniffZeroTier - Network: UDP
- Route-driven: supported
- Detection (best-effort, no port binding):
- fragment header pattern (indicator, fragNo/fragTotal, hops)
- plaintext HELLO pattern (flags/cipher/verb/proto version and timestamp range)
- Output:
Protocol=zerotier
RDP
- Entry:
common/protocol/rdp/sniff.go→SniffRDP - Network: TCP
- Route-driven: supported
- Detection:
- TPKT (version=3) + X.224 Connection Request (TPDU type=0xe0)
- Output:
Protocol=rdp
MQTT
- Entry:
common/protocol/mqtt/sniff.go→SniffMQTT - Network: TCP
- Route-driven: supported
- Detection:
- CONNECT (0x10)
- parse Remaining Length (varint)
- validate protocol name (
MQTT/MQIsdp), level, reserved bits in flags, etc.
protocol.ErrProtoNeedMoreData:- returned when it looks like CONNECT but the buffer is not long enough to validate
- Output:
Protocol=mqtt
NTP
- Entry:
common/protocol/ntp/sniff.go→SniffNTP - Network: UDP
- Route-driven: supported
- Detection:
- at least 48 bytes, total length is a multiple of 4
- client request mode (mode=3), version range (1..4), stratum=0
- Output:
Protocol=ntp
Postgres
- Entry:
common/protocol/postgres/sniff.go→SniffPostgres - Network: TCP
- Route-driven: supported
- Detection:
- read first 8 bytes (msgLen + code) of StartupMessage / SSLRequest / etc.
- return:
postgrespostgres+sslpostgres+gssencpostgres+cancel
- Output: one of the above
IKEv2
- Entry:
common/protocol/ike/sniff.go→SniffIKEv2 - Network: UDP
- Route-driven: supported
- Detection:
- header length >= 28
- version byte is 0x20 (IKEv2)
- exchange type is one of common values
- Output:
Protocol=ikev2
DTLS
- Entry:
common/protocol/dtls/sniff.go→SniffDTLS - Network: UDP
- Route-driven: supported
- Detection:
- DTLS record header is 13 bytes
- validate content type (20/21/22/23) and version (0xfeff/0xfefe/0xfefd)
- validate fragment length bounds
- Output:
Protocol=dtls
BitTorrent (incl. uTP)
- Entry:
common/protocol/bittorrent/bittorrent.go- TCP:
SniffBittorrent - UDP(uTP):
SniffUTP
- TCP:
- Network: TCP / UDP
- Detection:
- TCP: match handshake header
0x13 + \"BitTorrent protocol\" - UDP(uTP): validate type/version, extension chain, and timestamp sanity (<= 24h from now)
- TCP: match handshake header
- Output:
Protocol=bittorrent
STUN / TURN
- Entry:
common/protocol/stun/sniff.go→SniffSTUN - Network: TCP / UDP
- Detection:
- validate magic cookie
0x2112A442, message length and alignment - decide
stunvsturnby method
- validate magic cookie
- Output:
Protocol=stunorProtocol=turn
SOCKS (4/5)
- Entry:
common/protocol/socks/sniff.go→SniffSOCKS4/SniffSOCKS5 - Network: TCP
- Detection:
- SOCKS5:
0x05+ nMethods - SOCKS4:
0x04+ cmd + minimal structure (null-terminated user id)
- SOCKS5:
- Output:
Protocol=socks4/Protocol=socks5- In routing rules you can use
protocol: [\"socks\"]as a prefix match
- In routing rules you can use
SSH
- Entry:
common/protocol/ssh/sniff.go→SniffSSH - Network: TCP
- Detection:
- match banner prefix
SSH- - newline within 255 bytes
- match banner prefix
- Output:
Protocol=ssh
Trojan
- Entry:
common/protocol/trojan/sniff.go→SniffTrojan(TLS ClientHello heuristic)app/dispatcher/sniffer.go(combined with TLS result)
- Network: TCP
- Enablement: routing rules require
protocol: "trojan"(evaluated for the current routing context) - Detection (conservative):
- TLS ClientHello: has SNI, has no ALPN, has no GREASE
- cipher suites count in [10, 20]
- Output:
Protocol=trojanDomainis not provided by Trojan itself; it is combined with the TLS domain result so domain routing / destination override keeps working