Contents

Inbounds & outbounds

The two halves of every proxy: how traffic enters and how it leaves.

Every Xray instance — client or server — is the same machine: inbounds accept connections, a dispatcher picks a route, outbounds carry the traffic away. Once you see these two halves and the tags that connect them, most of an Xray config stops being magic.

A proxy is two halves glued by two small interfaces

Xray splits every proxy into a listener half and a dialer half, and they never talk to each other directly — an inbound hands its connection to the dispatcher, and the dispatcher picks an outbound. The contract lives in proxy/proxy.go:

proxy/proxy.go features/inbound/inbound.go features/outbound/outbound.go
// An Inbound processes inbound connections.
type Inbound interface {
	Network() []net.Network
	Process(context.Context, net.Network, stat.Connection, routing.Dispatcher) error
}

// An Outbound process outbound connections.
type Outbound interface {
	Process(context.Context, *transport.Link, internet.Dialer) error
}

The asymmetry in the signatures tells the whole story. An inbound’s Process receives a live stat.Connection — a real accepted socket — plus a routing.Dispatcher to hand traffic off to. An outbound’s Process receives no socket at all: it gets a *transport.Link (a reader/writer pair that the dispatcher backs with in-memory pipes) and an internet.Dialer, and must create its own connection to the outside world.

A client instance: one inbound, two outbounds, and the dispatcher matching each connection to a tag

Inbounds own sockets; outbounds own nothing until traffic arrives

An inbound handler is a bundle of always-on workers — one listening socket per port, per network. NewAlwaysOnInboundHandler loops over the configured port range and appends, for each port, a tcpWorker and/or udpWorker depending on which networks the protocol reports via Network(); Start() starts them all and they run until shutdown. The tag does double duty: it’s stamped into the context before the proxy object is even created (so protocols like TUN can read it at construction time), and each worker stamps a fresh session.Inbound carrying the same tag onto every connection it accepts — so traffic always knows which inbound it came through.

An outbound handler is the opposite: its Start() is literally return nil. It holds no socket and does no work until the dispatcher calls Dispatch, which — unless mux intercepts the link first — ends in h.proxy.Process(ctx, link, h). Note the handler passes itself as the dialer: that is how sender settings (stream config, sendThrough, proxySettings chaining) wrap every connection the proxy protocol makes.

app/proxyman/inbound/always.go app/proxyman/outbound/handler.go

Traffic finds its way out by tag, and only by tag

The router never returns a handler — it returns a string, and the dispatcher resolves that string against the outbound manager’s tag map. The selection order in routedDispatch is: forced tag (platform detour) → routing rule’s outboundTag → the default handler, which is simply the first outbound in your config (AddHandler keeps the first handler it sees as defaultHandler).

app/dispatcher/default.go app/proxyman/outbound/outbound.go
{
	"inbounds": [{ "tag": "socks-in", "port": 1080, "protocol": "socks" }],
	"outbounds": [
		{ "tag": "proxy", "protocol": "vless" /* … */ }, // first = default
		{ "tag": "direct", "protocol": "freedom" },
		{ "tag": "block", "protocol": "blackhole" }
	],
	"routing": {
		"rules": [
			{ "domain": ["geosite:category-ads-all"], "outboundTag": "block" },
			{ "domain": ["geosite:cn"], "outboundTag": "direct" }
		]
	}
}

Two more consequences of the tag map: duplicate tags are a startup error, and an untagged outbound can never be selected by a rule at all — GetHandler only consults taggedHandler.

Don't

Rely on outbound order and leave tags off

An untagged outbound is unreachable by routing rules. If it isn’t first in the list, nothing will ever use it — silently.

Do

Tag every outbound, and put your intended default first

Rules reference tags; the first entry is the fallback for anything no rule matches. Both mechanisms should be deliberate.

Every chain has to end in freedom

Somewhere, some outbound must stop wrapping traffic in protocols and just dial the real destination — that outbound is freedom. Its Process optionally resolves the target domain to an IP, calls dialer.Dial(ctx, dialDest), and copies bytes with no framing on top. That’s why a server config’s outbound list is usually just freedom first: after the inbound decrypts a VLESS tunnel, the dispatcher’s default handler carries the inner connection straight to the real internet. On a client, the same proxy appears as the direct outbound for split routing.

proxy/freedom/freedom.go

Outbounds can dial through each other — in memory

Chaining two outbounds never touches a localhost socket. When an outbound’s sender config sets proxySettings.tag, its Dial method looks up the other handler by tag, creates two in-memory pipes, and runs go handler.Dispatch(ctx, link) — the “connection” between the two hops is a pair of buffer channels inside the same process, with another session.Outbound appended to the context so each hop still knows its own target and tag.

app/proxyman/outbound/handler.go