diff --git a/proxy/tun/README.md b/proxy/tun/README.md index d03817a58..6aca00f82 100644 --- a/proxy/tun/README.md +++ b/proxy/tun/README.md @@ -15,7 +15,8 @@ Plainly enabling it in the config probably will result nothing, or lock your rou ## DETAILS 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. \ +When configured explicitly, Windows and Linux can apply interface addresses from `gateway`, while macOS and FreeBSD use the first IPv4 prefix from `gateway` for the point-to-point address. \ +Without `gateway`, the systems differ: Xray assigns no address on Linux, Windows gives the interface link-local addresses itself (an IPv6 one at once, an IPv4 one from `169.254.0.0/16` after a few seconds), and macOS and FreeBSD use `169.254.10.1/30`. \ Windows, Linux and macOS can also apply system routes from `autoSystemRoutingTable`. 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. @@ -33,7 +34,7 @@ It uses `resolvectl`, which means it applies only when all of these hold: - 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. +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. 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: diff --git a/proxy/tun/tun_linux.go b/proxy/tun/tun_linux.go index 7e1c29172..d59757dd0 100644 --- a/proxy/tun/tun_linux.go +++ b/proxy/tun/tun_linux.go @@ -53,23 +53,29 @@ var resolvectlRunner = func(name string, args ...string) ([]byte, error) { } // 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. +// first IPv4 gateway, or without one, the first IPv6 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) { + var first6 netip.Addr for _, address := range gateway { prefix, err := netip.ParsePrefix(address) if err != nil { continue } addr := prefix.Addr() - if !addr.Is4() { - continue + if addr.Is4() { + return addr, addr.Next(), true } - return addr, addr.Next(), true + if !first6.IsValid() { + first6 = addr + } + } + if first6.IsValid() { + return first6, first6.Next(), true } return netip.Addr{}, netip.Addr{}, false } @@ -115,11 +121,11 @@ const probeSourcePort = 49152 // Overridable for tests. var verifyDNSRouting = func(ctx context.Context, inboundTag, source, address string) error { ip, err := netip.ParseAddr(address) - if err != nil || !ip.Is4() { + if err != nil { return errors.New("invalid DNS address ", address).Base(err) } src, err := netip.ParseAddr(source) - if err != nil || !src.Is4() { + if err != nil || src.Is4() != ip.Is4() { return errors.New("invalid source address ", source).Base(err) } @@ -202,7 +208,7 @@ func (t *LinuxTun) ConfigureSystemDNS(ctx context.Context, inboundTag string) er source, address, ok := systemDNSAddrs(t.options.Gateway) if !ok { - return errors.New("no IPv4 gateway, cannot derive a system DNS address") + return errors.New("no gateway, cannot derive a system DNS address") } iface := t.ifaceName() diff --git a/proxy/tun/tun_linux_dns_route_test.go b/proxy/tun/tun_linux_dns_route_test.go index f9a05112d..120ef7e10 100644 --- a/proxy/tun/tun_linux_dns_route_test.go +++ b/proxy/tun/tun_linux_dns_route_test.go @@ -191,3 +191,15 @@ func TestVerifyDNSRoutingDecisions(t *testing.T) { }) } } + +// Without an IPv4 gateway, the takeover uses the first IPv6 one, and the probe +// carries IPv6 addresses. +func TestVerifyDNSRoutingIPv6(t *testing.T) { + ctx := newRouteTestContext(t, true, udpNameServer([]byte{9, 9, 9, 9}), []*router.RoutingRule{port53Rule()}) + if err := verifyDNSRouting(ctx, routeTestInboundTag, "fc00::1", "fc00::2"); err != nil { + t.Fatalf("expected the takeover to be accepted, got: %v", err) + } + if err := verifyDNSRouting(ctx, routeTestInboundTag, routeTestSource, "fc00::2"); err == nil { + t.Fatal("expected mixed IPv4 and IPv6 addresses to be refused") + } +} diff --git a/proxy/tun/tun_linux_dns_test.go b/proxy/tun/tun_linux_dns_test.go index 71f3bf4b7..d1c2fdfa7 100644 --- a/proxy/tun/tun_linux_dns_test.go +++ b/proxy/tun/tun_linux_dns_test.go @@ -103,7 +103,7 @@ func TestConfigureSystemDNSNoGateway(t *testing.T) { t1.options.Gateway = nil if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { - t.Fatal("expected an error when no IPv4 gateway is configured") + t.Fatal("expected an error when no gateway is configured") } if len(*probes) != 0 { t.Errorf("routing probe must not run without a gateway, got %d calls", len(*probes)) @@ -351,9 +351,18 @@ func TestSystemDNSAddrs(t *testing.T) { wantOK: false, }, { - name: "ipv6 only", - gateway: []string{"fc00::1/64"}, - wantOK: false, + name: "ipv6 only", + gateway: []string{"fc00::1/64"}, + wantSource: "fc00::1", + wantDNS: "fc00::2", + wantOK: true, + }, + { + name: "first ipv6 without ipv4", + gateway: []string{"fc00::1/64", "fd00::1/64"}, + wantSource: "fc00::1", + wantDNS: "fc00::2", + wantOK: true, }, }