Freedom & friends
The utility handlers that make routing work — freedom dials direct, blackhole drops, dns hijacks lookups, loopback re-routes, dokodemo catches transparent traffic.
Not every outbound is a tunnel. Four of Xray’s handlers never speak a proxy protocol at all — they are the plumbing the router reaches for once a rule has decided what to do with a connection: send it straight out, drop it, answer it, or send it around the loop again. A fifth handler, dokodemo, sits on the other side as an inbound that swallows whatever the OS redirects at it. Learn these five and the routing table stops being magic.
Freedom is the exit — and it resolves DNS itself
freedom is the handler that actually touches the internet, and everything else in a config exists to decide which connections reach it. Its Process just dials the target and copies bytes both ways, giving the dial up to five attempts with a delay that grows by 100 ms per failure (the helper is named ExponentialBackoff, but read common/retry/retry.go — it backs off linearly):
err := retry.ExponentialBackoff(5, 100).On(func() error {
dialDest := destination
if h.config.DomainStrategy.HasStrategy() && dialDest.Address.Family().IsDomain() {
strategy := h.config.DomainStrategy
// …
ips, err := internet.LookupForIP(dialDest.Address.Domain(), strategy, outGateway)
// …
dialDest.Address = net.IPAddress(ips[dice.Roll(len(ips))])
}
rawConn, err := dialer.Dial(ctx, dialDest)
// …
}) The non-obvious part is domainStrategy. With the default "asis", freedom hands the raw domain to the system dialer. But set "useIP" / "forceIP" and freedom resolves the name through Xray’s own DNS before dialing, then picks one address at random with dice.Roll. forceIP* variants make a failed lookup fatal; the useIP* variants fall back to dialing the domain directly. It even caches each domain’s resolved UDP address so a chatty domain doesn’t scatter its packets across a rotating IP set.
For UDP there is a parallel trick: noises prepends junk packets (type rand, or fixed str/hex/base64) before your first real datagram to poison flow-fingerprinting. There is a DNS safety valve, but it is narrower than you’d guess: NoisePacketWriter skips noise only when UDPOverride.Port == 53 — that is, when a redirect override points the outbound at port 53. DNS that freedom dials without a redirect still gets noised.
Blackhole drops the connection — politely, if you ask
blackhole is a one-way sink: it never reads the payload, optionally writes one canned response, then interrupts the writer so the caller unblocks. That is the entire handler:
nBytes := h.response.WriteTo(link.Writer)
if nBytes > 0 {
// Sleep a little here to make sure the response is sent to client.
time.Sleep(time.Second)
}
common.Interrupt(link.Writer) By default response is NoneResponse and nothing is written — the connection just dies. Configure "response": { "type": "http" } and it writes a canned HTTP/1.1 403 Forbidden before closing, which turns a blocked HTTP request into an instant error page instead of a browser hang.
The dns outbound answers queries instead of forwarding them
The dns handler hijacks a DNS query mid-flight and serves it from Xray’s built-in resolver rather than shipping it to whatever server the client addressed. Process parses each message; if it is an A/AAAA question, handleIPQuery calls the internal dns.Client (with FakeEnable: true, so FakeDNS answers are honored) and builds the response packet locally — the upstream server is never contacted for those.
Non-IP questions are governed by nonIPQuery, which defaults to "reject":
nonIPQuery | What happens to a non-A/AAAA query |
|---|---|
reject (default) | A REFUSED response is synthesized and returned |
drop | The query is silently swallowed |
skip | The query is forwarded to the real DNS server |
Those three are the only values the config parser accepts. blockTypes kills specific record types even when they would otherwise be forwarded — the blocked query gets a REFUSED answer if nonIPQuery is reject, and vanishes silently otherwise. A common use is blocking HTTPS/type-65 records so browsers fall back to plain A lookups you can steer. Point a routing rule for UDP/TCP port 53 at this outbound and every app on the box inherits your DNS policy.
Loopback feeds a connection back through routing
loopback is the outbound that isn’t an exit — it re-dispatches the connection to the router under a new inbound tag, so one config can make routing decisions in two passes. It calls dispatcherInstance.Dispatch with inbound.Tag set to its configured inboundTag and marks the content SkipDNSResolve = true:
content := new(session.Content)
content.SkipDNSResolve = true
ctx = session.ContextWithContent(ctx, content)
// …
inbound.Tag = l.config.InboundTag
ctx = session.ContextWithInbound(ctx, inbound)
rawConn, err := l.dispatcherInstance.Dispatch(ctx, dialDest) That second trip is the point: rules can match on the loopback tag to apply a different chain — for example, route a domain through an upstream proxy, but only after a first pass has rewritten or filtered it.
Dokodemo catches whatever the OS redirects at it
dokodemo-door is the transparent inbound — it accepts a connection with no proxy header and recovers the original destination the kernel was hiding. With followRedirect: true, Process takes the real target from the outbound context, which the inbound worker filled in from the kernel (SO_ORIGINAL_DST for REDIRECT, the socket’s local address for TPROXY). If that yields nothing and dokodemo itself terminates TLS, it falls back to the handshake’s HandshakeContextServerName — the hook MITM routing relies on:
Assume dokodemo needs a target
Without followRedirect, dokodemo forwards everything to one fixed address:port — useful as a static port-forwarder, useless for capturing a whole device’s traffic.
Pair followRedirect with tproxy
In TPROXY mode dokodemo forges UDP replies from the original destination via FakeUDP, honoring the socket mark, so transparent UDP (QUIC, DNS) survives the round trip — the plumbing behind a full-tunnel gateway.
Together these five handlers are why a routing table reads like intent: freedom means go, blackhole means no, dns means I’ll answer that, loopback means think again, and dokodemo is the door the whole system walks in through.