From 747b15333330b688b016d0888decb4b4d3d26316 Mon Sep 17 00:00:00 2001 From: patterniha <71074308+patterniha@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:46:45 +0330 Subject: [PATCH] TUN inbound: Error out when `autoSystemWfpBlockLeak` or `autoSystemDnsToGateway` cannot apply As asked in review, rather than run without them: - The config is rejected, also by xray -test, for autoSystemWfpBlockLeak without autoSystemRoutingTable, or with "dns" but without dns, on Windows, and for autoSystemDnsToGateway without gateway on Linux. - Xray does not start when the filters cannot be added, now on every Windows version, or when the system DNS cannot be set on Linux, instead of logging it and running without them. Co-Authored-By: Claude Opus 5.5 --- infra/conf/tun.go | 19 +++++++++++++++++++ infra/conf/tun_test.go | 35 +++++++++++++++++++++++++++++------ proxy/tun/README.md | 26 +++++++++++++------------- proxy/tun/handler.go | 6 ++++-- proxy/tun/tun_linux.go | 4 ++-- proxy/tun/tun_windows.go | 37 +++++++++++++++---------------------- 6 files changed, 82 insertions(+), 45 deletions(-) diff --git a/infra/conf/tun.go b/infra/conf/tun.go index aabddb980..a358b035c 100644 --- a/infra/conf/tun.go +++ b/infra/conf/tun.go @@ -5,6 +5,8 @@ import ( "fmt" "math/big" "net" + "runtime" + "slices" "strconv" "strings" @@ -45,6 +47,23 @@ func (v *TunConfig) Build() (proto.Message, error) { return nil, errors.New("unknown autoSystemWfpBlockLeak value: ", leak) } } + // Each option needs other settings on the system it takes effect on: the + // filters go along with the routes of autoSystemRoutingTable, "dns" lets + // DNS through the TUN only, and autoSystemDnsToGateway points the system + // DNS at the gateway. + switch runtime.GOOS { + case "windows": + if len(config.AutoSystemWfpBlockLeak) > 0 && len(v.AutoSystemRoutingTable) == 0 { + return nil, errors.New("autoSystemWfpBlockLeak needs autoSystemRoutingTable to be set") + } + if slices.Contains(config.AutoSystemWfpBlockLeak, "dns") && len(v.DNS) == 0 { + return nil, errors.New(`autoSystemWfpBlockLeak "dns" needs dns to be set`) + } + case "linux": + if v.AutoSystemDnsToGateway && len(v.Gateway) == 0 { + return nil, errors.New("autoSystemDnsToGateway needs gateway to be set") + } + } if v.AutoOutboundsInterface != nil { config.AutoOutboundsInterface = *v.AutoOutboundsInterface } diff --git a/infra/conf/tun_test.go b/infra/conf/tun_test.go index 697e0fdf0..8e8693b74 100644 --- a/infra/conf/tun_test.go +++ b/infra/conf/tun_test.go @@ -2,6 +2,7 @@ package conf_test import ( "encoding/json" + "runtime" "testing" . "github.com/xtls/xray-core/infra/conf" @@ -20,23 +21,45 @@ func TestTunConfigAutoSystem(t *testing.T) { Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500}, }, { - Input: `{"name": "xray0", "autoSystemDnsToGateway": true}`, + Input: `{"name": "xray0", "gateway": ["10.0.0.1/24"], "autoSystemDnsToGateway": true}`, Parser: loadJSON(creator), - Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500, AutoSystemDnsToGateway: true}, + Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500, Gateway: []string{"10.0.0.1/24"}, AutoSystemDnsToGateway: true}, }, { - Input: `{"name": "xray0", "autoSystemWfpBlockLeak": ["dns", "misconfigtun"]}`, + Input: `{"name": "xray0", "dns": ["1.1.1.1"], "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["dns", "misconfigtun"]}`, Parser: loadJSON(creator), - Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500, AutoSystemWfpBlockLeak: []string{"dns", "misconfigtun"}}, + Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500, DNS: []string{"1.1.1.1"}, AutoSystemRoutingTable: []string{"0.0.0.0/0"}, AutoOutboundsInterface: "auto", AutoSystemWfpBlockLeak: []string{"dns", "misconfigtun"}}, }, { - Input: `{"name": "xray0", "autoSystemWfpBlockLeak": ["DNS"]}`, + Input: `{"name": "xray0", "dns": ["1.1.1.1"], "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["DNS"]}`, Parser: loadJSON(creator), - Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500, AutoSystemWfpBlockLeak: []string{"dns"}}, + Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500, DNS: []string{"1.1.1.1"}, AutoSystemRoutingTable: []string{"0.0.0.0/0"}, AutoOutboundsInterface: "auto", AutoSystemWfpBlockLeak: []string{"dns"}}, }, }) } +// TestTunConfigAutoSystemNeeds checks that an option is rejected without the +// setting it needs, only on the system it takes effect on. +func TestTunConfigAutoSystemNeeds(t *testing.T) { + for _, c := range []struct { + input string + goos string // where it is rejected + }{ + {`{"name": "xray0", "autoSystemWfpBlockLeak": ["misconfigtun"]}`, "windows"}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["misconfigtun"]}`, ""}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["dns"]}`, "windows"}, + {`{"name": "xray0", "autoSystemDnsToGateway": true}`, "linux"}, + } { + config := new(TunConfig) + if err := json.Unmarshal([]byte(c.input), config); err != nil { + t.Fatal(err) + } + if _, err := config.Build(); (err != nil) != (runtime.GOOS == c.goos) { + t.Errorf("%s on %s: error = %v", c.input, runtime.GOOS, err) + } + } +} + func TestTunConfigAutoSystemWfpBlockLeakUnknown(t *testing.T) { config := new(TunConfig) if err := json.Unmarshal([]byte(`{"name": "xray0", "autoSystemWfpBlockLeak": ["dns", "ip"]}`), config); err != nil { diff --git a/proxy/tun/README.md b/proxy/tun/README.md index 4f4c4226b..70cc66ff6 100644 --- a/proxy/tun/README.md +++ b/proxy/tun/README.md @@ -27,16 +27,16 @@ Examples of how to achieve this on a simple Linux system (Ubuntu with systemd-ne On Linux, setting `autoSystemDnsToGateway` 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: +It uses `resolvectl`, which means it only works when all of these hold. Where Xray can tell that one does not, it does not start: - 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 enabled and actually managing DNS (installed but not running is not enough) - 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`, or without one the first IPv6 `gateway`, incremented by one (e.g. `192.168.100.1/30` -> `192.168.100.2`, `fc00::1/64` -> `fc00::2`). Without any `gateway`, the option does nothing. 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. +The address handed over is the first IPv4 `gateway`, or without one the first IPv6 `gateway`, incremented by one (e.g. `192.168.100.1/30` -> `192.168.100.2`, `fc00::1/64` -> `fc00::2`). Without any `gateway`, the config is rejected. 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: +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 DNS is left alone and Xray does not start. In practice this means you also need a routing rule sending the interface's port 53 to a `dns` outbound, for example: ```json "routing": { @@ -50,19 +50,19 @@ The check is a preflight, not a proof for arbitrary rules. It sends its query fr 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 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, and Xray does not start. 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: +Where it cannot apply, Xray does not start, rather than run with the leak described in XTLS/Xray-core#6454, so leave the option off there: | 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 | +| Alpine, Void, Devuan, OpenRC-based, OpenWrt | no `resolvectl`, does not start | +| DNS managed by dnsmasq / unbound / BIND / static `resolv.conf` | unreachable by `resolvectl`, does not start | +| Containers without a systemd-resolved daemon | does not start | +| systemd older than 240 | `default-route` unavailable, does not start | 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. @@ -201,15 +201,15 @@ After the start network adapter with the name you chose in the config will be cr When `dns` is set, those servers are applied to the adapter. Windows is kept from registering the TUN's addresses in DNS, and its DNS cache is flushed when the TUN starts and stops. -With `autoSystemWfpBlockLeak` and `autoSystemRoutingTable` set, Xray also adds Windows Filtering Platform filters that keep two kinds of traffic of every program but Xray itself from leaving outside the TUN, each chosen by a value in the list, e.g. `"autoSystemWfpBlockLeak": ["dns", "misconfigtun"]`: -- `"dns"`: with `dns` set, DNS (port 53) only goes through the TUN. Windows keeps sending name queries to the DNS servers of the other interfaces as well, out through those interfaces whatever the routes say, and other programs reach a resolver on the local network (e.g. `192.168.1.1` handed out by DHCP) through its more specific LAN route instead of the TUN. On Windows 11 and Server 2022 and later, where those queries may also go over HTTPS or TLS, Windows' DNS Client service cannot connect outside the TUN at all, except for name resolution on the local network (LLMNR, mDNS). The `dns` servers therefore have to lie within `gateway` or `autoSystemRoutingTable` (a warning is logged otherwise), and DNS servers that should be reached directly belong in Xray's own `dns` settings. +With `autoSystemWfpBlockLeak`, which needs `autoSystemRoutingTable` (the config is rejected otherwise), Xray also adds Windows Filtering Platform filters that keep two kinds of traffic of every program but Xray itself from leaving outside the TUN, each chosen by a value in the list, e.g. `"autoSystemWfpBlockLeak": ["dns", "misconfigtun"]`: +- `"dns"` (needs `dns`, the config is rejected otherwise): DNS (port 53) only goes through the TUN. Windows keeps sending name queries to the DNS servers of the other interfaces as well, out through those interfaces whatever the routes say, and other programs reach a resolver on the local network (e.g. `192.168.1.1` handed out by DHCP) through its more specific LAN route instead of the TUN. On Windows 11 and Server 2022 and later, where those queries may also go over HTTPS or TLS, Windows' DNS Client service cannot connect outside the TUN at all, except for name resolution on the local network (LLMNR, mDNS). The `dns` servers therefore have to lie within `gateway` or `autoSystemRoutingTable` (a warning is logged otherwise), and DNS servers that should be reached directly belong in Xray's own `dns` settings. - `"misconfigtun"`: an IP version without routes in `autoSystemRoutingTable`, IPv4 or IPv6, is blocked entirely, in both directions, as it would bypass the TUN. Only loopback and what Windows itself needs on the local link (DHCP, and for IPv6 neighbor and multicast listener discovery) remain allowed. An address of that version in `gateway` is not needed: without one, Windows gives the TUN link-local addresses itself, an IPv6 one at once and an IPv4 one from `169.254.0.0/16` after some seconds (until then, IPv4 routed to the TUN is unreachable), and what is routed to the TUN goes through it with those. With the filters in place, Xray's own connections out also get past Windows Firewall's block rules (other firewalls may still block them), while connections to Xray's inbounds stay subject to them. Names that Xray resolves through the system resolver, such as an outbound's server address given as a domain with the default `AsIs` domain strategy, would be looked up by Windows on Xray's behalf, and those queries would then go into the TUN too. While DNS is restricted this way and `autoOutboundsInterface` is in use (the default with `autoSystemRoutingTable`), Xray therefore resolves them itself, with its own queries to the DNS servers of the other interfaces. That bypasses Windows' DNS cache, and its name resolution on the local network (LLMNR, mDNS): a server address given as a domain is looked up again for every connection, and a DNS server that does not answer delays each lookup. Having Xray's own `dns` resolve it, through the outbound's `sockopt.domainStrategy`, avoids that. The `localhost` DNS server queries the same servers whenever `autoOutboundsInterface` is in use. Both skip the TUN's own DNS servers, unless another interface uses them as well: queried from Xray itself, they would lead back into it, or nowhere. -If the filters cannot be added, the TUN does not start (on Windows 10 and later; older versions only log a warning). They are removed when Xray exits. Not covered is name resolution on the local network (LLMNR, mDNS, NetBIOS), except over an IP version that is blocked. +If the filters cannot be added, Xray does not start. They are removed when Xray exits. Not covered is name resolution on the local network (LLMNR, mDNS, NetBIOS), except over an IP version that is blocked. `autoSystemWfpBlockLeak` (Windows only) is empty by default, as the filters break some setups: with `"dns"`, a local DNS resolver other programs use (e.g. on `127.0.0.1:53`), the DNS of another VPN on its own interface, virtual machines whose NAT resolves names on the host, or signing in to a captive portal; with `"misconfigtun"`, IPv4 or IPv6 on the local network while no route of that version leads to the TUN. Without the filters, DNS may leak as described above. To keep an IP version out of the TUN on purpose while still blocking DNS leaks, use only `["dns"]`. diff --git a/proxy/tun/handler.go b/proxy/tun/handler.go index 0e2de9461..c13625b0b 100644 --- a/proxy/tun/handler.go +++ b/proxy/tun/handler.go @@ -166,12 +166,14 @@ func (t *Handler) Start() error { } // Platform-specific system DNS takeover, where the platform implements it. - // Non-fatal: a failure leaves DNS management with the OS. + // Rather no TUN than one that the system DNS bypasses. 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") + _ = tunStack.Close() + _ = tunInterface.Close() + return errors.New("unable to set the system DNS (remove autoSystemDnsToGateway to run without)").Base(err) } } diff --git a/proxy/tun/tun_linux.go b/proxy/tun/tun_linux.go index d59757dd0..78e2dd400 100644 --- a/proxy/tun/tun_linux.go +++ b/proxy/tun/tun_linux.go @@ -188,8 +188,8 @@ var verifyDNSRouting = func(ctx context.Context, inboundTag, source, address str // // 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. +// resolution is left to the OS and an error returned. The caller does not start +// the TUN on an error, as the system DNS would bypass it. func (t *LinuxTun) ConfigureSystemDNS(ctx context.Context, inboundTag string) error { if !t.options.AutoSystemDnsToGateway { return nil diff --git a/proxy/tun/tun_windows.go b/proxy/tun/tun_windows.go index 9c7ce9f37..d0101c2f7 100644 --- a/proxy/tun/tun_windows.go +++ b/proxy/tun/tun_windows.go @@ -266,29 +266,22 @@ startOver: blocked = append(blocked, b.what) } } - what := strings.Join(blocked, " and ") - // Rather no TUN than a leaking one. Before Windows 10 the filters are - // untested, and sing-box's broke its TUN there (SagerNet/sing-box#3659), - // so older versions only get a warning. - if major, _, _ := windows.RtlGetNtVersionNumbers(); major >= 10 { - return errors.New("unable to block ", what, " outside the TUN (remove autoSystemWfpBlockLeak to run without)").Base(err) + // Rather no TUN than a leaking one. + return errors.New("unable to block ", strings.Join(blocked, " and "), " outside the TUN (remove autoSystemWfpBlockLeak to run without)").Base(err) + } + errors.LogInfo(context.Background(), "[tun] outside the TUN, blocked DNS: ", blockDNS, ", blocked IPv4: ", blockIPv4, ", blocked IPv6: ", blockIPv6) + if blockDNS { + covered := slices.Clone(addresses) + for _, route := range routesData { + covered = append(covered, route.Destination) } - errors.LogWarningInner(context.Background(), err, "[tun] unable to block ", what, " outside the TUN, leaks are possible") - } else { - errors.LogInfo(context.Background(), "[tun] outside the TUN, blocked DNS: ", blockDNS, ", blocked IPv4: ", blockIPv4, ", blocked IPv6: ", blockIPv6) - if blockDNS { - covered := slices.Clone(addresses) - for _, route := range routesData { - covered = append(covered, route.Destination) - } - for _, server := range dnsOutsideTUN(dns, covered) { - errors.LogWarning(context.Background(), "[tun] DNS server ", server, " is in neither gateway nor autoSystemRoutingTable, so queries to it cannot go through the TUN and are blocked") - } - // With updater, the dialer controllers bind Xray's own sockets - // to the physical interface. - if updater != nil { - t.resolver = resolveOnOwn() - } + for _, server := range dnsOutsideTUN(dns, covered) { + errors.LogWarning(context.Background(), "[tun] DNS server ", server, " is in neither gateway nor autoSystemRoutingTable, so queries to it cannot go through the TUN and are blocked") + } + // With updater, the dialer controllers bind Xray's own sockets + // to the physical interface. + if updater != nil { + t.resolver = resolveOnOwn() } } }