Session context
The per-connection metadata threaded through every stage via context.
Every connection through Xray crosses at least four subsystems — inbound proxy, sniffer,
router, outbound handler — and they never call each other with explicit session arguments.
Instead, a handful of records ride on the standard context.Context, and every stage reads or
mutates the same records. This page is the map for that glue.
One connection, four records on the context
When a TCP connection is accepted, the inbound worker builds the entire session state up
front — before a single payload byte is read. tcpWorker.callback stamps four things onto a
fresh child context:
sid := session.NewID() // random uint32, never 0, not crypto-secure
ctx = c.ContextWithID(ctx, sid)
ctx = session.ContextWithOutbounds(ctx, []*session.Outbound{{}})
ctx = session.ContextWithInbound(ctx, &session.Inbound{
Source: net.DestinationFromAddr(conn.RemoteAddr()),
Local: net.DestinationFromAddr(conn.LocalAddr()),
Gateway: net.TCPDestination(w.address, w.port),
Tag: w.tag,
Conn: conn,
})
ctx = session.ContextWithContent(ctx, content) // carries the SniffingRequest Inbound— where the connection came from:Source(the client address),Local,Gateway, the inboundTag, and later the authenticatedUser.[]*Outbound— where it is going:Target,RouteTarget, the chosen outboundTag.Content— what it carries: sniffedProtocol, HTTPAttributes, the sniffing config.Sockopt— set once per inbound at config time inNewHandlerwhenever the listener hassockoptstream settings, carrying the fwmark; the dokodemo inbound reads it back so the spoofed-source UDP sockets it opens for TPROXY replies (FakeUDP(addr, mark)) carry the same mark.
The keys are plain small integers (ctx.SessionKey, an int type) — key 0 is the session ID
and lives in the tiny common/ctx package whose only import is context, so low-level
transport code can tag log lines with the session ID without importing the whole session dependency tree. The error logger reads key 0 back through IDFromContext on every errors.LogInfo(ctx, …) call — that is the [1234567890] prefix in your logs.
The records are pointers — stages mutate them, not the context
context.WithValue is immutable, so Xray stores pointers and mutates through them. A value
written deep in the pipeline becomes visible to code that captured the context much earlier,
without any new context being created. The clearest case is sniffing: Dispatch returns the
inbound’s link immediately and sniffs on a goroutine afterwards —
ob := outbounds[len(outbounds)-1]
go func() {
result, err := sniffer(ctx, cReader, sniffingRequest.MetadataOnly, destination.Network)
if err == nil {
content.Protocol = result.Protocol() // mutates the shared *Content
}
if err == nil && d.shouldOverride(ctx, result, sniffingRequest, destination) {
destination.Address = net.ParseAddress(result.Domain())
// …
if sniffingRequest.RouteOnly && protocol != "fakedns" /* … */ {
ob.RouteTarget = destination // mutates the shared *Outbound
} else {
ob.Target = destination
}
}
d.routedDispatch(ctx, outbound, destination)
}() Later, routedDispatch writes the routing decision back the same way: ob.Tag = handler.Tag(). Anything still holding this context — stats, logs, the observatory — sees the
final tag for free.
The router never touches context.Context — it gets a snapshot view
Routing rules read a routing.Context adapter, not the raw context, built by AsRoutingContext from the same three pointers:
func AsRoutingContext(ctx context.Context) routing.Context {
outbounds := session.OutboundsFromContext(ctx)
ob := outbounds[len(outbounds)-1]
return &Context{
Inbound: session.InboundFromContext(ctx),
Outbound: ob,
Content: session.ContentFromContext(ctx),
}
} GetSourceIPs/GetSourcePort come from Inbound.Source, GetUser from Inbound.User.Email, GetProtocol and GetAttributes from Content. The interesting one is GetTargetDomain: it
prefers Outbound.RouteTarget over Outbound.Target. That is what routeOnly sniffing means
in your config —
{
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls"],
"routeOnly": true
}
} with routeOnly the sniffed domain is written into RouteTarget only, so your domain rules
match, but the outbound still dials the untouched original IP in Target.
Stats and access logs hang off the same records
Per-user traffic counters exist only because Inbound.User is on the context. When the
dispatcher builds the uplink/downlink pipes in getLink, it reads the authenticated user and —
if the user’s level policy enables user stats — wraps the writers in counting shims keyed by
email:
sessionInbound := session.InboundFromContext(ctx)
user := sessionInbound.User
p := d.policy.ForLevel(user.Level)
if p.Stats.UserUplink {
name := "user>>>" + user.Email + ">>>traffic>>>uplink"
if c, _ := stats.GetOrRegisterCounter(d.stats, name); c != nil {
inboundLink.Writer = &SizeStatWriter{Counter: c, Writer: inboundLink.Writer}
}
} The online-user map works the same way, plus context lifetime: it adds Inbound.Source.Address and registers context.AfterFunc(ctx, …) to remove the IP the moment
the session’s context is cancelled. The access log’s Detour field is assembled in routedDispatch from Inbound.Tag and the freshly chosen handler tag — the arrow encodes how
the route was picked: inTag -> outTag for a router rule, inTag >> outTag for the default
outbound, inTag ==> outTag for a platform-forced detour.