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:
// 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.
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.
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).
{
"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.
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.
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.
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.