Contents

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:

app/proxyman/inbound/worker.go common/session/session.go common/ctx/context.go
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 inbound Tag, and later the authenticated User.
  • []*Outbound — where it is going: Target, RouteTarget, the chosen outbound Tag.
  • Content — what it carries: sniffed Protocol, HTTP Attributes, the sniffing config.
  • Sockopt — set once per inbound at config time in NewHandler whenever the listener has sockopt stream 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.

Every stage receives the same ctx; records set early are mutated by later stages in place

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 —

app/dispatcher/default.go
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:

features/routing/session/context.go
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:

app/dispatcher/default.go
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.