Reverse proxying
Bridge and portal: exposing a service behind NAT through the tunnel backwards.
You have a service on a machine with no public IP — a home server, a box behind carrier-grade
NAT — and you want to reach it from the internet. Port forwarding is impossible, so the usual
proxy direction is useless. Xray’s reverse feature turns the tunnel around: the hidden machine
(bridge) dials out to a public machine (portal), and visitor traffic rides back over
connections the bridge opened.
The bridge dials out, so NAT never matters
The machine behind NAT initiates every connection — the portal never needs to reach it. Bridge.monitor runs every 2 seconds and, whenever it has no active workers (or the average
load per worker exceeds 16 connections), spawns a BridgeWorker that opens a new outgoing link.
What is remarkable is how it opens that link: there is no dialer in the bridge at all. It
injects a fake inbound connection into its own router — Dispatch toward net.DomainAddress(domain) on port 0, with the bridge’s tag as the inbound tag — and your
routing rules do the rest, steering it into whatever outbound reaches the portal:
// bridge machine (behind NAT)
{
"reverse": {
"bridges": [{ "tag": "bridge", "domain": "private.example.com" }]
},
"routing": {
"rules": [
// the bridge's own registration connections → tunnel to the portal
{ "inboundTag": ["bridge"], "domain": ["full:private.example.com"], "outboundTag": "tunnel" },
// traffic arriving FROM the portal → the local service
{ "inboundTag": ["bridge"], "outboundTag": "local" }
]
},
"outbounds": [
{ "tag": "tunnel", "protocol": "vless" /* …portal's public address… */ },
{ "tag": "local", "protocol": "freedom", "settings": { "redirect": "127.0.0.1:80" } }
]
} The portal is an outbound, not an inbound
The portal registers itself as an outbound handler (ohm.AddHandler in Portal.Start), so
on the public server, reverse traffic is routed to it like any egress. Two very different
kinds of connections arrive at that one tag, and Portal.HandleConnection splits them by
destination: if the target domain equals the portal’s domain, this is a bridge registering —
the link is wrapped into a mux.ClientWorker and added to a worker pool. Anything else is
visitor traffic, dispatched into the least-loaded live tunnel by StaticMuxPicker.
// portal machine (public IP)
{
"reverse": {
"portals": [{ "tag": "portal", "domain": "private.example.com" }]
},
"inbounds": [
{ "tag": "tunnel-in", "port": 443, "protocol": "vless" /* …bridge logs in here… */ },
{ "tag": "visitors", "port": 8080, "protocol": "dokodemo-door", "settings": { "address": "127.0.0.1", "port": 80 } }
],
"routing": {
"rules": [
// bridge registrations (target == the magic domain) → portal
{ "inboundTag": ["tunnel-in"], "domain": ["full:private.example.com"], "outboundTag": "portal" },
// real visitors → the same portal, which relays them over a tunnel
{ "inboundTag": ["visitors"], "outboundTag": "portal" }
]
}
} Roles invert inside the tunnel
The side that dialed becomes the server. Each bridge link is wrapped in a mux.ServerWorker on the bridge and a mux.ClientWorker on the portal — the exact opposite of
the TCP direction. That inversion is the whole trick: mux lets the client side open streams,
so the portal (mux client) can open a new stream toward the bridge whenever a visitor shows up,
without any new connection crossing the NAT. When a stream arrives, BridgeWorker.Dispatch hands it to the local router under the bridge’s inbound tag, and your freedom rule delivers
it to the service.
One stream per link is special: the portal opens a control connection to the internal domain reverse (a hardcoded constant), and the bridge answers it with an in-memory pipe instead of
routing it anywhere.
The pool feeds and drains itself
Links are disposable and the pool self-heals. Over the control stream the portal sends a
protobuf Control heartbeat roughly every 10 seconds; a bridge worker that hears nothing for
60 seconds is torn down, and the 2-second monitor immediately dials a replacement. Once a link
has carried more than 256 total connections, the portal marks it DRAIN — the bridge stops
counting it as active, spawns a fresh link, and the old one finishes its in-flight streams
instead of dying mid-transfer.
The result is a service published from behind NAT with nothing listening on the hidden machine, no port forwarding, and — from the outside — nothing but ordinary outbound proxy traffic from the bridge to the portal.