From 47a2c2ffdcc171704832bf8a55851e4c19e2a1f9 Mon Sep 17 00:00:00 2001 From: youugiuhiuh <260548057+youugiuhiuh@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:07:53 +0800 Subject: [PATCH] TUN inbound: Add `autoSystemDNS` on Linux (to TUN's `gateway`) (#6773) https://github.com/XTLS/Xray-core/issues/6454#issuecomment-4931311976 https://github.com/XTLS/Xray-core/pull/6773#issuecomment-5755516423 https://github.com/XTLS/Xray-core/pull/6807#issuecomment-5807844210 --- app/dns/dns.go | 22 ++ app/dns/dns_internal_test.go | 59 ++++ infra/conf/tun.go | 2 + proxy/tun/README.md | 45 ++- proxy/tun/config.pb.go | 13 +- proxy/tun/config.proto | 1 + proxy/tun/handler.go | 10 + proxy/tun/tun_linux.go | 245 +++++++++++++++ proxy/tun/tun_linux_dns_route_test.go | 193 ++++++++++++ proxy/tun/tun_linux_dns_test.go | 436 ++++++++++++++++++++++++++ 10 files changed, 1023 insertions(+), 3 deletions(-) create mode 100644 app/dns/dns_internal_test.go create mode 100644 proxy/tun/tun_linux_dns_route_test.go create mode 100644 proxy/tun/tun_linux_dns_test.go diff --git a/app/dns/dns.go b/app/dns/dns.go index b750fce26..6fb88d695 100644 --- a/app/dns/dns.go +++ b/app/dns/dns.go @@ -212,6 +212,28 @@ func (s *DNS) IsOwnLink(ctx context.Context) bool { return false } +// MayUseSystemResolver reports whether any name server configured here could +// still resolve through the system resolver. That is what happens when no name +// server is configured at all, and it is also what a name server pointed at +// "localhost" does. Callers that are about to redirect the system resolver need +// to know, because a resolution path that reaches it would then loop back to +// them. +// +// Any such server is enough: name servers can be selected per domain, so a +// single local one makes some query reach the system resolver even when +// independent upstreams are configured alongside it. +func (s *DNS) MayUseSystemResolver() bool { + if len(s.clients) == 0 { + return true + } + for _, client := range s.clients { + if _, isLocal := client.server.(*LocalNameServer); isLocal { + return true + } + } + return false +} + // LookupIP implements dns.Client. func (s *DNS) LookupIP(domain string, option dns.IPOption) ([]net.IP, uint32, error) { // Normalize the FQDN form query diff --git a/app/dns/dns_internal_test.go b/app/dns/dns_internal_test.go new file mode 100644 index 000000000..7614efd8b --- /dev/null +++ b/app/dns/dns_internal_test.go @@ -0,0 +1,59 @@ +package dns + +import ( + "context" + "testing" + + "github.com/xtls/xray-core/common/net" + feature_dns "github.com/xtls/xray-core/features/dns" +) + +// fakeServer stands in for any name server that is not the system resolver. +type fakeServer struct{} + +func (fakeServer) Name() string { return "fake" } +func (fakeServer) IsDisableCache() bool { return false } +func (fakeServer) QueryIP(context.Context, string, feature_dns.IPOption) ([]net.IP, uint32, error) { + return nil, 0, nil +} + +// Callers that are about to redirect the system resolver rely on this to tell +// whether any resolution path could still reach the system resolver, so the +// mixed shape has to be reported as reachable: a domain-specific rule can +// select the system resolver even when an independent upstream also exists. +func TestMayUseSystemResolver(t *testing.T) { + tests := []struct { + name string + clients []*Client + want bool + }{ + { + name: "no clients at all", + want: true, + }, + { + name: "only the system resolver", + clients: []*Client{{server: NewLocalNameServer()}}, + want: true, + }, + { + name: "the system resolver alongside an independent name server", + clients: []*Client{{server: fakeServer{}}, {server: NewLocalNameServer()}}, + want: true, + }, + { + name: "only independent name servers", + clients: []*Client{{server: fakeServer{}}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &DNS{clients: tt.clients} + if got := server.MayUseSystemResolver(); got != tt.want { + t.Errorf("MayUseSystemResolver() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/infra/conf/tun.go b/infra/conf/tun.go index 73e71a991..96c57c314 100644 --- a/infra/conf/tun.go +++ b/infra/conf/tun.go @@ -20,6 +20,7 @@ type TunConfig struct { UserLevel uint32 `json:"userLevel"` AutoSystemRoutingTable []string `json:"autoSystemRoutingTable"` AutoOutboundsInterface *string `json:"autoOutboundsInterface"` + AutoSystemDNS bool `json:"autoSystemDNS"` } func (v *TunConfig) Build() (proto.Message, error) { @@ -31,6 +32,7 @@ func (v *TunConfig) Build() (proto.Message, error) { DNS: v.DNS, UserLevel: v.UserLevel, AutoSystemRoutingTable: v.AutoSystemRoutingTable, + AutoSystemDns: v.AutoSystemDNS, } if v.AutoOutboundsInterface != nil { config.AutoOutboundsInterface = *v.AutoOutboundsInterface diff --git a/proxy/tun/README.md b/proxy/tun/README.md index 18c4c3345..0a3e49350 100644 --- a/proxy/tun/README.md +++ b/proxy/tun/README.md @@ -17,11 +17,54 @@ Plainly enabling it in the config probably will result nothing, or lock your rou By default, enabling the feature will only bring the tun interface up. \ When configured explicitly, Windows and Linux can apply interface addresses from `gateway`, while macOS uses the first IPv4 prefix from `gateway` to configure the utun point-to-point address. \ Windows, Linux and macOS can also apply system routes from `autoSystemRoutingTable`. -Linux and macOS do not configure system DNS from the `dns` field; system DNS remains managed by the OS or distribution-specific network services. \ +macOS does not configure system DNS from the `dns` field, and neither does Linux by default; system DNS remains managed by the OS or distribution-specific network services. \ For more advanced routing policies or rules, OS level configuration can still manage the named interface (e.g. xray0) when it appears. This keeps complex system level routing and rules in a single place of responsibility - the OS itself. \ Examples of how to achieve this on a simple Linux system (Ubuntu with systemd-networkd) can be found at the end of this README. +### SYSTEM DNS ON LINUX (`autoSystemDNS`) + +On Linux, setting `autoSystemDNS` to `true` lets the inbound point the system resolver at the tun interface, so name lookups resolve through Xray instead of going out over the physical link. It is off by default, and it is Linux-only. + +It uses `resolvectl`, which means it applies only when all of these hold: + +- the system runs systemd and `resolvectl` is on `PATH` +- `systemd-resolved` is enabled and actually managing DNS (installed but not running has no effect) +- systemd-resolved is version 240 or newer, where `default-route` exists +- no `dns` upstream resolves through the system resolver, directly or through its own bootstrap (see below) + +The address handed over is the first IPv4 `gateway` incremented by one (e.g. `192.168.100.1/30` -> `192.168.100.2`). It is not taken from `dns`: handing `1.1.1.1` to `resolvectl dns` would make systemd-resolved query that server directly over the physical link, which is the leak this option exists to close. + +Because that address has to actually answer, the takeover is checked before it happens. A query from the interface address to that address is routed through the configured rules, and host-wide DNS is only changed when the result is a DNS-capable outbound. Otherwise the option does nothing and DNS is left to the OS. In practice this means you also need a routing rule sending the interface's port 53 to a `dns` outbound, for example: + +```json +"routing": { + "rules": [ + { "type": "field", "inboundTag": ["tun"], "port": 53, "outboundTag": "dns" } + ] +} +``` + +The check is a preflight, not a proof for arbitrary rules. It sends its query from the interface address and from a representative ephemeral source port, so a rule that matches on the source port cannot be predicted ahead of time: if the interface's port 53 reaches the `dns` outbound only from some source ports, the takeover is accepted and queries from the other ports fail. Supported configurations are those where the DNS path does not depend on the source port, that is, where the interface's port 53 reaches a `dns` outbound whatever its source. + +It is also a check for the dependencies it knows about, not a proof that no indirect one exists. A hostname-based upstream that bootstraps through system DNS is the case in point: `https+local://dns.google/dns-query` resolves its own hostname with `DialSystem`, so once the takeover is in place that bootstrap goes `resolved -> TUN -> DNS outbound -> bootstrap -> resolved` and the query times out. The preflight does not see it, because the dependency sits in the upstream's bootstrap rather than in the clients it inspects. Upstream resolution, bootstrap included, therefore has to stay independent of the resolver path being redirected; configuring the address instead of the hostname, or resolving the hostname beforehand, avoids it. + +The upstream requirement in the list above matters as much as the routing rule. With no name servers configured, Core resolves through a client that forwards to the system resolver; pointing the system resolver at the TUN would then close a loop through the DNS outbound, `resolved -> TUN -> DNS outbound -> system resolver -> resolved`, and resolution stops. The takeover is refused in that case. + +The same applies to a name server pointed at `localhost`, and to a `dns` section that is present but lists no name servers. One such upstream is enough to refuse the takeover even when independent upstreams are configured alongside it: name servers are selected per domain, so a domain-specific rule can still choose the local one, and the loop then affects whichever domains reach it. The check is deliberately broader than the loop it observed, because the alternative would be to drop a name server the user configured. + +Where it does not apply, DNS is left alone and the leak described in XTLS/Xray-core#6454 remains: + +| Environment | Behaviour | +|---|---| +| systemd distribution with systemd-resolved enabled | applies | +| Alpine, Void, Devuan, OpenRC-based, OpenWrt | no `resolvectl`, skipped | +| DNS managed by dnsmasq / unbound / BIND / static `resolv.conf` | unreachable by `resolvectl`, skipped | +| Containers without a systemd-resolved daemon | skipped | +| systemd older than 240 | `default-route` unavailable, skipped | + +On `Close()` the setting is reverted. It is **not** reverted if the process is killed with `SIGKILL`, since a process cannot handle that signal; run `resolvectl revert ` to clean up by hand. An application that brings its own DNS endpoint is unaffected either way — this only covers the system resolver. + Due to this inbound not actually being a proxy, the configuration ignore required listen and port options, and never listen on any port. \ Here is simple Xray config snippet to enable the inbound: ``` diff --git a/proxy/tun/config.pb.go b/proxy/tun/config.pb.go index 33bc2ba6f..e565ea47c 100644 --- a/proxy/tun/config.pb.go +++ b/proxy/tun/config.pb.go @@ -32,6 +32,7 @@ type Config struct { AutoSystemRoutingTable []string `protobuf:"bytes,6,rep,name=auto_system_routing_table,json=autoSystemRoutingTable,proto3" json:"auto_system_routing_table,omitempty"` AutoOutboundsInterface string `protobuf:"bytes,7,opt,name=auto_outbounds_interface,json=autoOutboundsInterface,proto3" json:"auto_outbounds_interface,omitempty"` Desc string `protobuf:"bytes,8,opt,name=desc,proto3" json:"desc,omitempty"` + AutoSystemDns bool `protobuf:"varint,9,opt,name=auto_system_dns,json=autoSystemDns,proto3" json:"auto_system_dns,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -122,11 +123,18 @@ func (x *Config) GetDesc() string { return "" } +func (x *Config) GetAutoSystemDns() bool { + if x != nil { + return x.AutoSystemDns + } + return false +} + var File_proxy_tun_config_proto protoreflect.FileDescriptor const file_proxy_tun_config_proto_rawDesc = "" + "\n" + - "\x16proxy/tun/config.proto\x12\x0exray.proxy.tun\"\x82\x02\n" + + "\x16proxy/tun/config.proto\x12\x0exray.proxy.tun\"\xaa\x02\n" + "\x06Config\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" + "\x03MTU\x18\x02 \x01(\rR\x03MTU\x12\x18\n" + @@ -136,7 +144,8 @@ const file_proxy_tun_config_proto_rawDesc = "" + "user_level\x18\x05 \x01(\rR\tuserLevel\x129\n" + "\x19auto_system_routing_table\x18\x06 \x03(\tR\x16autoSystemRoutingTable\x128\n" + "\x18auto_outbounds_interface\x18\a \x01(\tR\x16autoOutboundsInterface\x12\x12\n" + - "\x04desc\x18\b \x01(\tR\x04descBL\n" + + "\x04desc\x18\b \x01(\tR\x04desc\x12&\n" + + "\x0fauto_system_dns\x18\t \x01(\bR\rautoSystemDnsBL\n" + "\x12com.xray.proxy.tunP\x01Z#github.com/xtls/xray-core/proxy/tun\xaa\x02\x0eXray.Proxy.Tunb\x06proto3" var ( diff --git a/proxy/tun/config.proto b/proxy/tun/config.proto index 376ac5af4..d29fa2c66 100644 --- a/proxy/tun/config.proto +++ b/proxy/tun/config.proto @@ -15,4 +15,5 @@ message Config { repeated string auto_system_routing_table = 6; string auto_outbounds_interface = 7; string desc = 8; + bool auto_system_dns = 9; } diff --git a/proxy/tun/handler.go b/proxy/tun/handler.go index 53c74a5e3..0e2de9461 100644 --- a/proxy/tun/handler.go +++ b/proxy/tun/handler.go @@ -165,6 +165,16 @@ func (t *Handler) Start() error { return err } + // Platform-specific system DNS takeover, where the platform implements it. + // Non-fatal: a failure leaves DNS management with the OS. + if c, ok := tunInterface.(interface { + ConfigureSystemDNS(context.Context, string) error + }); ok { + if err := c.ConfigureSystemDNS(t.ctx, t.tag); err != nil { + errors.LogInfoInner(t.ctx, err, "[tun] system DNS not configured") + } + } + t.stack = tunStack t.tun = tunInterface diff --git a/proxy/tun/tun_linux.go b/proxy/tun/tun_linux.go index b2c5d35a1..5136d501a 100644 --- a/proxy/tun/tun_linux.go +++ b/proxy/tun/tun_linux.go @@ -6,12 +6,24 @@ import ( "context" "net" "net/netip" + "os/exec" "strconv" "sync" "github.com/vishvananda/netlink" + appdns "github.com/xtls/xray-core/app/dns" "github.com/xtls/xray-core/common/errors" + xnet "github.com/xtls/xray-core/common/net" "github.com/xtls/xray-core/common/platform" + "github.com/xtls/xray-core/common/serial" + "github.com/xtls/xray-core/common/session" + "github.com/xtls/xray-core/core" + feature_dns "github.com/xtls/xray-core/features/dns" + "github.com/xtls/xray-core/features/dns/localdns" + "github.com/xtls/xray-core/features/outbound" + "github.com/xtls/xray-core/features/routing" + routingsession "github.com/xtls/xray-core/features/routing/session" + "github.com/xtls/xray-core/proxy/dns" "golang.org/x/sys/unix" "gvisor.dev/gvisor/pkg/tcpip/link/fdbased" "gvisor.dev/gvisor/pkg/tcpip/stack" @@ -30,6 +42,238 @@ type LinuxTun struct { systemRoutes []netlink.Route routeMonitorStop chan struct{} routeMonitorOnce sync.Once + + systemDNSSet bool + systemDNSDirty bool +} + +// resolvectlRunner runs a resolvectl command. Overridable for tests. +var resolvectlRunner = func(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).CombinedOutput() +} + +// systemDNSAddrs derives the addresses used for the system DNS takeover from the +// first IPv4 gateway: the gateway address itself is what a query from this +// interface appears to come from, and the next address is what the resolver is +// pointed at. The latter belongs to the TUN and is answered inside Xray; +// handing the configured public resolvers to resolvectl instead would leave the +// system querying them directly over the physical link, defeating the point of +// the TUN. +func systemDNSAddrs(gateway []string) (source, dns netip.Addr, ok bool) { + for _, address := range gateway { + prefix, err := netip.ParsePrefix(address) + if err != nil { + continue + } + addr := prefix.Addr() + if !addr.Is4() { + continue + } + return addr, addr.Next(), true + } + return netip.Addr{}, netip.Addr{}, false +} + +func buildResolvectlArgs(action, iface string, extra ...string) []string { + args := make([]string, 0, 2+len(extra)) + args = append(args, action, iface) + args = append(args, extra...) + return args +} + +func runResolvectl(action, iface string, extra ...string) error { + args := buildResolvectlArgs(action, iface, extra...) + if _, err := resolvectlRunner("resolvectl", args...); err != nil { + return errors.New("resolvectl ", action, " failed").Base(err) + } + return nil +} + +// ifaceName returns the TUN interface name, or empty when the link is not +// available. Callers must treat empty as "nothing to configure". +func (t *LinuxTun) ifaceName() string { + if t.tunLink == nil { + return "" + } + attrs := t.tunLink.Attrs() + if attrs == nil { + return "" + } + return attrs.Name +} + +// probeSourcePort is a representative client port for the routing probe. A real +// query arrives from an ephemeral port that cannot be known in advance, so this +// only matters for a rule that matches on a source port. +const probeSourcePort = 49152 + +// verifyDNSRouting reports whether a DNS query to address would actually be +// handled. Redirecting the system resolver at an address nothing answers would +// break name resolution outright, so the takeover only proceeds when routing +// hands such a query to a DNS-capable outbound. +// +// Overridable for tests. +var verifyDNSRouting = func(ctx context.Context, inboundTag, source, address string) error { + ip, err := netip.ParseAddr(address) + if err != nil || !ip.Is4() { + return errors.New("invalid DNS address ", address).Base(err) + } + src, err := netip.ParseAddr(source) + if err != nil || !src.Is4() { + return errors.New("invalid source address ", source).Base(err) + } + + instance := core.MustFromContext(ctx) + + // Any resolution path that could still reach the system resolver has to be + // refused, because pointing the system resolver at the TUN would close a + // loop through the DNS outbound. With no `dns` section Core installs such a + // client; with a `dns` section that has no name servers app/dns falls back + // to one; and a name server pointed at "localhost" is one even when + // independent upstreams are configured alongside it, because name servers + // are selected per domain. + switch dnsFeature := instance.GetFeature(feature_dns.ClientType()).(type) { + case *localdns.Client: + return errors.New("DNS feature is the system resolver, takeover would loop") + case *appdns.DNS: + if dnsFeature.MayUseSystemResolver() { + return errors.New("DNS configuration may resolve through the system resolver, takeover would loop") + } + } + + router, ok := instance.GetFeature(routing.RouterType()).(routing.Router) + if !ok { + return errors.New("router feature unavailable") + } + + // A real query from this interface carries a source address, and rules may + // match on it, so the probe has to carry one too. + queryCtx := session.ContextWithInbound(ctx, &session.Inbound{ + Name: "tun", + Tag: inboundTag, + Source: xnet.UDPDestination(xnet.IPAddress(src.AsSlice()), probeSourcePort), + }) + queryCtx = session.ContextWithOutbounds(queryCtx, []*session.Outbound{{ + Target: xnet.UDPDestination(xnet.IPAddress(ip.AsSlice()), 53), + }}) + + route, err := router.PickRoute(routingsession.AsRoutingContext(queryCtx)) + if err != nil { + return errors.New("no route for ", address, ":53").Base(err) + } + + manager, ok := instance.GetFeature(outbound.ManagerType()).(outbound.Manager) + if !ok { + return errors.New("outbound manager unavailable") + } + + handler := manager.GetHandler(route.GetOutboundTag()) + if handler == nil { + return errors.New("outbound ", route.GetOutboundTag(), " does not exist") + } + if settings := handler.ProxySettings(); settings == nil || settings.Type != serial.GetMessageType(&dns.Config{}) { + return errors.New("outbound ", route.GetOutboundTag(), " does not handle DNS") + } + return nil +} + +// ConfigureSystemDNS points systemd-resolved at this interface so name lookups +// resolve through Xray instead of leaking to the physical link. +// +// It acts only when the config opts in, and it verifies the data path first: +// unless a query to the advertised address would actually be handled, host-wide +// resolution is left to the OS, which is the documented default. Errors are +// returned to the caller, which treats them as non-fatal. +func (t *LinuxTun) ConfigureSystemDNS(ctx context.Context, inboundTag string) error { + if !t.options.AutoSystemDns { + return nil + } + if t.systemDNSSet { + return nil + } + + // A previous revert may have failed. Retry before applying anything, so a + // dirty resolver does not silently outlive the attempt to clean it up. + if t.systemDNSDirty { + if err := t.revertSystemDNS(); err != nil { + return errors.New("previous system DNS revert still failing").Base(err) + } + } + + source, address, ok := systemDNSAddrs(t.options.Gateway) + if !ok { + return errors.New("no IPv4 gateway, cannot derive a system DNS address") + } + + iface := t.ifaceName() + if iface == "" { + return errors.New("interface not available") + } + + if err := verifyDNSRouting(ctx, inboundTag, source.String(), address.String()); err != nil { + return errors.New("no DNS path at ", address.String(), ":53").Base(err) + } + + // Applied as a sequence with rollback: a half-configured resolver would be + // worse than none at all. + if err := runResolvectl("dns", iface, address.String()); err != nil { + return errors.New("resolvectl dns failed").Base(err) + } + if err := runResolvectl("domain", iface, "~."); err != nil { + return t.rollbackSystemDNS(iface, errors.New("resolvectl domain failed").Base(err)) + } + if err := runResolvectl("default-route", iface, "true"); err != nil { + return t.rollbackSystemDNS(iface, errors.New("resolvectl default-route failed").Base(err)) + } + + t.systemDNSSet = true + errors.LogInfo(ctx, "[tun] system DNS set to ", address.String(), " on ", iface) + return nil +} + +// rollbackSystemDNS undoes a partially applied takeover. A failed revert is +// recorded so the next attempt retries it, and is reported rather than +// swallowed. +func (t *LinuxTun) rollbackSystemDNS(iface string, cause error) error { + if err := runResolvectl("revert", iface); err != nil { + t.systemDNSDirty = true + // Combine, because Base overwrites: reporting only the cause would hide + // the revert failure, and reporting only the revert failure would hide + // why the revert was attempted. + return errors.New("revert failed, per-link DNS settings may remain").Base(errors.Combine(err, cause)) + } + return cause +} + +// revertSystemDNS issues the revert and keeps the dirty flag in step with the +// outcome. +func (t *LinuxTun) revertSystemDNS() error { + err := runResolvectl("revert", t.ifaceName()) + t.systemDNSDirty = err != nil + if err != nil { + return err + } + t.systemDNSSet = false + return nil +} + +// unsetSystemDNS hands DNS back to the OS. Only meaningful when +// ConfigureSystemDNS applied something, or a previous revert failed. +func (t *LinuxTun) unsetSystemDNS() { + if !t.systemDNSSet && !t.systemDNSDirty { + return + } + + if t.ifaceName() == "" { + // The link is gone, and its per-link settings went with it. + t.systemDNSSet = false + t.systemDNSDirty = false + return + } + + if err := t.revertSystemDNS(); err != nil { + errors.LogInfoInner(context.Background(), err, "[tun] failed to revert system DNS; per-link settings may remain until revert succeeds") + } } // LinuxTun implements Tun @@ -200,6 +444,7 @@ func (t *LinuxTun) Close() error { } }) + t.unsetSystemDNS() _ = t.unsetSystemRoutes() _ = t.unsetInterfaceAddresses() diff --git a/proxy/tun/tun_linux_dns_route_test.go b/proxy/tun/tun_linux_dns_route_test.go new file mode 100644 index 000000000..f9a05112d --- /dev/null +++ b/proxy/tun/tun_linux_dns_route_test.go @@ -0,0 +1,193 @@ +//go:build linux && !android + +package tun + +import ( + "context" + "strings" + "testing" + + "github.com/xtls/xray-core/app/dispatcher" + appdns "github.com/xtls/xray-core/app/dns" + "github.com/xtls/xray-core/app/proxyman" + _ "github.com/xtls/xray-core/app/proxyman/inbound" + _ "github.com/xtls/xray-core/app/proxyman/outbound" + "github.com/xtls/xray-core/app/router" + "github.com/xtls/xray-core/common/geodata" + "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/common/serial" + "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/proxy/blackhole" + proxydns "github.com/xtls/xray-core/proxy/dns" + "github.com/xtls/xray-core/proxy/freedom" +) + +const ( + routeTestInboundTag = "tun" + routeTestSource = "192.168.100.1" + routeTestDNSAddress = "192.168.100.2" +) + +// port53Rule sends DNS queries arriving from the interface to the dns outbound. +func port53Rule() *router.RoutingRule { + return &router.RoutingRule{ + InboundTag: []string{routeTestInboundTag}, + PortList: &net.PortList{Range: []*net.PortRange{net.SinglePortRange(53)}}, + TargetTag: &router.RoutingRule_Tag{Tag: "dns"}, + } +} + +// sourceBlockRule diverts traffic from one address, which is the shape of a rule +// that only matches because the real request carries a source. +func sourceBlockRule(ip []byte) *router.RoutingRule { + return &router.RoutingRule{ + SourceIp: []*geodata.IPRule{{ + Value: &geodata.IPRule_Custom{ + Custom: &geodata.CIDRRule{ + Cidr: &geodata.CIDR{Ip: ip, Prefix: 32}, + }, + }, + }}, + TargetTag: &router.RoutingRule_Tag{Tag: "block"}, + } +} + +// newRouteTestContext builds a real but unstarted instance: no TUN device, no +// running resolver. The instance is placed in the context through the key core +// exports for tests. +func newRouteTestContext(t *testing.T, withDNSApp bool, nameServers []*appdns.NameServer, rules []*router.RoutingRule) context.Context { + t.Helper() + + apps := []*serial.TypedMessage{ + serial.ToTypedMessage(&dispatcher.Config{}), + serial.ToTypedMessage(&proxyman.InboundConfig{}), + serial.ToTypedMessage(&proxyman.OutboundConfig{}), + serial.ToTypedMessage(&router.Config{Rule: rules}), + } + if withDNSApp { + apps = append(apps, serial.ToTypedMessage(&appdns.Config{NameServer: nameServers})) + } + + instance, err := core.New(&core.Config{ + App: apps, + Outbound: []*core.OutboundHandlerConfig{ + {Tag: "direct", ProxySettings: serial.ToTypedMessage(&freedom.Config{})}, + {Tag: "dns", ProxySettings: serial.ToTypedMessage(&proxydns.Config{})}, + {Tag: "block", ProxySettings: serial.ToTypedMessage(&blackhole.Config{})}, + }, + }) + if err != nil { + t.Fatalf("core.New: %v", err) + } + t.Cleanup(func() { _ = instance.Close() }) + + return context.WithValue(context.Background(), core.XrayKey(1), instance) +} + +func udpNameServer(ip []byte) []*appdns.NameServer { + return []*appdns.NameServer{{ + Address: &net.Endpoint{ + Network: net.Network_UDP, + Address: &net.IPOrDomain{Address: &net.IPOrDomain_Ip{Ip: ip}}, + Port: 53, + }, + }} +} + +// localNameServer is a name server pointed at "localhost", which app/dns +// resolves through the system resolver. +func localNameServer() *appdns.NameServer { + return &appdns.NameServer{ + Address: &net.Endpoint{ + Network: net.Network_UDP, + Address: &net.IPOrDomain{Address: &net.IPOrDomain_Domain{Domain: "localhost"}}, + Port: 53, + }, + } +} + +// These drive the real feature lookup and the real router. verifyDNSRouting is +// the same function ConfigureSystemDNS calls, so a false positive here is a +// false positive in the takeover decision itself, which is what assertions on +// the resolvectl arguments could never catch. +func TestVerifyDNSRoutingDecisions(t *testing.T) { + tests := []struct { + name string + withDNSApp bool + nameServers []*appdns.NameServer + rules []*router.RoutingRule + wantErr string + }{ + { + name: "independent upstream reaches the dns outbound", + withDNSApp: true, + nameServers: udpNameServer([]byte{9, 9, 9, 9}), + rules: []*router.RoutingRule{port53Rule()}, + wantErr: "", + }, + { + name: "no dns section falls back to the system resolver", + rules: []*router.RoutingRule{port53Rule()}, + wantErr: "system resolver", + }, + { + name: "dns section without name servers falls back too", + withDNSApp: true, + rules: []*router.RoutingRule{port53Rule()}, + wantErr: "system resolver", + }, + { + // An independent upstream is not enough on its own: name servers are + // selected per domain, so a local one can still be the one chosen. + // The refusal is deliberately domain-agnostic for that reason. + name: "a local name server alongside an independent one", + withDNSApp: true, + nameServers: append(udpNameServer([]byte{9, 9, 9, 9}), localNameServer()), + rules: []*router.RoutingRule{port53Rule()}, + wantErr: "system resolver", + }, + { + name: "a rule on the interface address diverts the real query", + withDNSApp: true, nameServers: udpNameServer([]byte{9, 9, 9, 9}), + rules: []*router.RoutingRule{ + sourceBlockRule([]byte{192, 168, 100, 1}), + port53Rule(), + }, + wantErr: "does not handle DNS", + }, + { + name: "a rule on another address does not match it", + withDNSApp: true, nameServers: udpNameServer([]byte{9, 9, 9, 9}), + rules: []*router.RoutingRule{ + sourceBlockRule([]byte{10, 0, 0, 1}), + port53Rule(), + }, + wantErr: "", + }, + { + name: "no rule matches the query", + withDNSApp: true, nameServers: udpNameServer([]byte{9, 9, 9, 9}), + wantErr: "no route", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := newRouteTestContext(t, tt.withDNSApp, tt.nameServers, tt.rules) + err := verifyDNSRouting(ctx, routeTestInboundTag, routeTestSource, routeTestDNSAddress) + + if tt.wantErr == "" { + if err != nil { + t.Fatalf("expected the takeover to be accepted, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected the takeover to be refused with %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErr) + } + }) + } +} diff --git a/proxy/tun/tun_linux_dns_test.go b/proxy/tun/tun_linux_dns_test.go new file mode 100644 index 000000000..429771d8b --- /dev/null +++ b/proxy/tun/tun_linux_dns_test.go @@ -0,0 +1,436 @@ +//go:build linux && !android + +package tun + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/vishvananda/netlink" +) + +// testLink returns a minimal netlink.Link whose Attrs().Name is name, so the +// DNS helpers can be exercised without a real TUN device. +func testLink(name string) netlink.Link { + return &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: name}} +} + +type probeCall struct { + inboundTag string + source string + address string +} + +// stubDNSRouting replaces the routing probe for the duration of a test and +// records how it was called, so tests can assert the probe is representative. +func stubDNSRouting(t *testing.T, err error) *[]probeCall { + t.Helper() + original := verifyDNSRouting + calls := []probeCall{} + verifyDNSRouting = func(_ context.Context, inboundTag, source, address string) error { + calls = append(calls, probeCall{inboundTag, source, address}) + return err + } + t.Cleanup(func() { verifyDNSRouting = original }) + return &calls +} + +// recorder installs a resolvectl stub for the duration of a test and returns the +// captured invocations. An empty failOn succeeds every call; otherwise the named +// subcommand fails. +func recorder(t *testing.T, failOn string) *[][]string { + t.Helper() + original := resolvectlRunner + calls := [][]string{} + resolvectlRunner = func(name string, args ...string) ([]byte, error) { + calls = append(calls, append([]string{name}, args...)) + if failOn != "" && len(args) > 0 && args[0] == failOn { + return nil, errors.New("boom") + } + return nil, nil + } + t.Cleanup(func() { resolvectlRunner = original }) + return &calls +} + +func optedInTun() *LinuxTun { + return &LinuxTun{ + options: &Config{ + Name: "xray_tun", + Gateway: []string{"192.168.100.1/30"}, + AutoSystemDns: true, + }, + tunLink: testLink("xray_tun"), + } +} + +func joined(calls [][]string) string { + parts := make([]string, 0, len(calls)) + for _, call := range calls { + parts = append(parts, strings.Join(call, " ")) + } + return strings.Join(parts, " | ") +} + +func TestConfigureSystemDNSDisabledByDefault(t *testing.T) { + probes := stubDNSRouting(t, nil) + calls := recorder(t, "") + + t1 := optedInTun() + t1.options.AutoSystemDns = false + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*probes) != 0 { + t.Errorf("routing probe must not run when disabled, got %d calls", len(*probes)) + } + if len(*calls) != 0 { + t.Errorf("resolvectl must not run when disabled, got %v", *calls) + } + if t1.systemDNSSet { + t.Error("systemDNSSet should stay false when disabled") + } +} + +func TestConfigureSystemDNSNoGateway(t *testing.T) { + probes := stubDNSRouting(t, nil) + calls := recorder(t, "") + + t1 := optedInTun() + t1.options.Gateway = nil + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { + t.Fatal("expected an error when no IPv4 gateway is configured") + } + if len(*probes) != 0 { + t.Errorf("routing probe must not run without a gateway, got %d calls", len(*probes)) + } + if len(*calls) != 0 { + t.Errorf("resolvectl must not run without a gateway, got %v", *calls) + } +} + +// This is the case the reviewer flagged: without a routed DNS path, pointing the +// system resolver at the derived address would break resolution outright. +func TestConfigureSystemDNSLeavesOSDNSWhenNoRoute(t *testing.T) { + probes := stubDNSRouting(t, errors.New("no route")) + calls := recorder(t, "") + + t1 := optedInTun() + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { + t.Fatal("expected an error when the DNS path is unverified") + } + if len(*probes) != 1 { + t.Errorf("routing probe should run once, got %d", len(*probes)) + } + if len(*calls) != 0 { + t.Errorf("system DNS must be left untouched, got %v", *calls) + } + if t1.systemDNSSet { + t.Error("systemDNSSet should stay false when the path is unverified") + } +} + +// A real query from the interface carries a source address, and rules may match +// on it, so the probe must not be source-less. +func TestConfigureSystemDNSProbeCarriesSource(t *testing.T) { + probes := stubDNSRouting(t, nil) + recorder(t, "") + + t1 := optedInTun() + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*probes) != 1 { + t.Fatalf("expected one probe call, got %d", len(*probes)) + } + got := (*probes)[0] + if got.source != "192.168.100.1" { + t.Errorf("probe source = %q, want the interface address %q", got.source, "192.168.100.1") + } + if got.address != "192.168.100.2" { + t.Errorf("probe address = %q, want %q", got.address, "192.168.100.2") + } + if got.inboundTag != "tun" { + t.Errorf("probe inbound tag = %q, want %q", got.inboundTag, "tun") + } +} + +func TestConfigureSystemDNSAppliesResolvectl(t *testing.T) { + stubDNSRouting(t, nil) + calls := recorder(t, "") + + t1 := optedInTun() + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !t1.systemDNSSet { + t.Fatal("systemDNSSet should be true after a successful takeover") + } + + want := "resolvectl dns xray_tun 192.168.100.2 | " + + "resolvectl domain xray_tun ~. | " + + "resolvectl default-route xray_tun true" + if got := joined(*calls); got != want { + t.Errorf("resolvectl calls = %q, want %q", got, want) + } +} + +func TestConfigureSystemDNSIdempotent(t *testing.T) { + stubDNSRouting(t, nil) + calls := recorder(t, "") + + t1 := optedInTun() + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + before := len(*calls) + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*calls) != before { + t.Errorf("second call must be a no-op, calls went %d -> %d", before, len(*calls)) + } +} + +// A half-applied resolver is worse than none, so a failure mid-sequence reverts. +func TestConfigureSystemDNSRollsBackOnPartialFailure(t *testing.T) { + stubDNSRouting(t, nil) + calls := recorder(t, "domain") + + t1 := optedInTun() + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { + t.Fatal("expected an error when a resolvectl step fails") + } + if t1.systemDNSSet { + t.Error("systemDNSSet should stay false after a failed takeover") + } + if t1.systemDNSDirty { + t.Error("a successful revert should not leave the resolver dirty") + } + if !strings.Contains(joined(*calls), "resolvectl revert xray_tun") { + t.Errorf("expected a revert after partial failure, got %q", joined(*calls)) + } +} + +// If the revert itself fails the settings may still be installed, so the state +// has to be remembered rather than silently dropped. +func TestConfigureSystemDNSRollbackFailureKeepsDirty(t *testing.T) { + stubDNSRouting(t, nil) + calls := recorder(t, "revert") + + t1 := optedInTun() + t1.options.Gateway = []string{"192.168.100.1/30"} + // Make only the rollback path fail: "dns" succeeds, "domain" fails, "revert" fails. + *calls = nil + + original := resolvectlRunner + defer func() { resolvectlRunner = original }() + resolvectlRunner = func(name string, args ...string) ([]byte, error) { + *calls = append(*calls, append([]string{name}, args...)) + if len(args) > 0 && (args[0] == "domain" || args[0] == "revert") { + return nil, errors.New("boom") + } + return nil, nil + } + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { + t.Fatal("expected an error when domain fails") + } + if !t1.systemDNSDirty { + t.Error("a failed revert must leave the resolver marked dirty") + } + if t1.systemDNSSet { + t.Error("systemDNSSet must stay false when the takeover did not complete") + } +} + +// A dirty resolver is retried before anything new is applied. +func TestConfigureSystemDNSRetriesDirtyBeforeApplying(t *testing.T) { + stubDNSRouting(t, nil) + calls := recorder(t, "") + + t1 := optedInTun() + t1.systemDNSDirty = true + + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := joined(*calls) + if !strings.HasPrefix(got, "resolvectl revert xray_tun") { + t.Errorf("expected the stale revert first, got %q", got) + } + if t1.systemDNSDirty { + t.Error("a successful retry should clear the dirty flag") + } + if !t1.systemDNSSet { + t.Error("the takeover should proceed once the retry succeeds") + } +} + +func TestUnsetSystemDNSReverts(t *testing.T) { + stubDNSRouting(t, nil) + calls := recorder(t, "") + + t1 := optedInTun() + if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { + t.Fatalf("setup failed: %v", err) + } + *calls = nil + + t1.unsetSystemDNS() + if t1.systemDNSSet { + t.Error("systemDNSSet should be false after unset") + } + if got := joined(*calls); got != "resolvectl revert xray_tun" { + t.Errorf("unset calls = %q, want %q", got, "resolvectl revert xray_tun") + } + + t1.unsetSystemDNS() + if len(*calls) != 1 { + t.Errorf("unsetSystemDNS must be idempotent, got %q", joined(*calls)) + } +} + +func TestUnsetSystemDNSKeepsDirtyWhenRevertFails(t *testing.T) { + stubDNSRouting(t, nil) + calls := recorder(t, "revert") + + t1 := optedInTun() + t1.systemDNSSet = true + + t1.unsetSystemDNS() + if !t1.systemDNSDirty { + t.Error("a failed revert during unset must be remembered") + } + if got := joined(*calls); !strings.Contains(got, "resolvectl revert xray_tun") { + t.Errorf("expected a revert attempt, got %q", got) + } +} + +func TestSystemDNSAddrs(t *testing.T) { + tests := []struct { + name string + gateway []string + wantSource string + wantDNS string + wantOK bool + }{ + { + name: "ipv4 /30", + gateway: []string{"192.168.100.1/30"}, + wantSource: "192.168.100.1", + wantDNS: "192.168.100.2", + wantOK: true, + }, + { + name: "ipv4 /16", + gateway: []string{"10.0.0.1/16"}, + wantSource: "10.0.0.1", + wantDNS: "10.0.0.2", + wantOK: true, + }, + { + name: "first ipv4 wins", + gateway: []string{"fc00::1/64", "172.18.0.1/30"}, + wantSource: "172.18.0.1", + wantDNS: "172.18.0.2", + wantOK: true, + }, + { + name: "no gateway", + gateway: nil, + wantOK: false, + }, + { + name: "ipv6 only", + gateway: []string{"fc00::1/64"}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source, dnsAddr, ok := systemDNSAddrs(tt.gateway) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !tt.wantOK { + return + } + if source.String() != tt.wantSource { + t.Errorf("source = %q, want %q", source.String(), tt.wantSource) + } + if dnsAddr.String() != tt.wantDNS { + t.Errorf("dns = %q, want %q", dnsAddr.String(), tt.wantDNS) + } + }) + } +} + +func TestBuildResolvectlArgs(t *testing.T) { + tests := []struct { + name string + action string + iface string + extra []string + want []string + }{ + { + name: "revert", + action: "revert", + iface: "xray_tun", + want: []string{"revert", "xray_tun"}, + }, + { + name: "dns single", + action: "dns", + iface: "xray_tun", + extra: []string{"192.168.100.2"}, + want: []string{"dns", "xray_tun", "192.168.100.2"}, + }, + { + name: "dns multiple", + action: "dns", + iface: "xray_tun", + extra: []string{"192.168.100.2", "fc00::2"}, + want: []string{"dns", "xray_tun", "192.168.100.2", "fc00::2"}, + }, + { + name: "domain wildcard", + action: "domain", + iface: "xray_tun", + extra: []string{"~."}, + want: []string{"domain", "xray_tun", "~."}, + }, + { + name: "default-route", + action: "default-route", + iface: "xray_tun", + extra: []string{"true"}, + want: []string{"default-route", "xray_tun", "true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildResolvectlArgs(tt.action, tt.iface, tt.extra...) + if len(got) != len(tt.want) { + t.Fatalf("args = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("args[%d] = %q, want %q (full: %v)", i, got[i], tt.want[i], got) + } + } + }) + } +}