From 96387150473461be90372fb1a217bbcda3f47b5f Mon Sep 17 00:00:00 2001 From: patterniha <71074308+patterniha@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:39:48 +0330 Subject: [PATCH] TUN inbound's `autoSystemWfpBlockLeak`: A CIDR in front of any value instead of "ipv4:" and "ipv6:"; Ports alone for TCP and UDP A CIDR in front of any value limits it to its addresses, an IPv6 one in brackets, e.g. "1.1.1.0/24:53", "[2001:db8::/32]:tcp/853" or "[::/0]:all": "0.0.0.0/0:" and "[::/0]:" take the place of "ipv4:" and "ipv6:". Like autoSystemRoutingTable, it takes CIDRs only, a single address as "8.8.8.8/32", and no GeoIP. "" or "-" alone stands for TCP and UDP, so "dns" is "53", "853" and "windnsclient" now. The filters follow: - One filter per layer permits what goes through the TUN, instead of one for each value. - "all" and "misconfigtun" outweigh the local network that "allexceptlan" leaves open, so with it, "192.168.1.5/32:all" still blocks that address. - The DNS Client service's mDNS and LLMNR stay allowed on the local network only, and only while no other value blocks them. - The warning about blocked DNS servers only names those a value blocks. Co-Authored-By: Claude Opus 5.5 --- infra/conf/tun_test.go | 43 ++-- proxy/tun/README.md | 10 +- proxy/tun/tun_windows.go | 36 ++-- proxy/tun/tun_windows_wfp.go | 327 +++++++++++++++++------------- proxy/tun/tun_windows_wfp_test.go | 110 +++++++--- proxy/tun/wfp_leak.go | 137 +++++++------ proxy/tun/wfp_leak_test.go | 77 ++++--- 7 files changed, 434 insertions(+), 306 deletions(-) diff --git a/infra/conf/tun_test.go b/infra/conf/tun_test.go index 1b6f858e0..25162079a 100644 --- a/infra/conf/tun_test.go +++ b/infra/conf/tun_test.go @@ -29,12 +29,12 @@ func TestTunConfigAutoSystem(t *testing.T) { { 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, DNS: []string{"1.1.1.1"}, AutoSystemRoutingTable: []string{"0.0.0.0/0"}, AutoOutboundsInterface: "auto", AutoSystemWfpBlockLeak: []string{"udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853", "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{"53", "853", "windnsclient", "misconfigtun"}}, }, { 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, DNS: []string{"1.1.1.1"}, AutoSystemRoutingTable: []string{"0.0.0.0/0"}, AutoOutboundsInterface: "auto", AutoSystemWfpBlockLeak: []string{"udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853"}}, + 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{"53", "853", "windnsclient"}}, }, { Input: `{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["TCP/853", "udp/853", "tcp/0853"]}`, @@ -49,17 +49,17 @@ func TestTunConfigAutoSystem(t *testing.T) { { Input: `{"name": "xray0", "dns": ["1.1.1.1"], "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["AllExceptLAN"]}`, Parser: loadJSON(creator), - 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{"allexceptlan", "udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853"}}, + 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{"allexceptlan", "53", "853", "windnsclient"}}, }, { - Input: `{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["IPv4:TCP/853", "ipv6:udp/10-20", "IPv6:ICMP"]}`, + Input: `{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["1.1.1.1/24:TCP/853", "[2001:DB8::/32]:udp/10-20", "[::/0]:ICMP", "853"]}`, Parser: loadJSON(creator), - Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500, AutoSystemRoutingTable: []string{"0.0.0.0/0"}, AutoOutboundsInterface: "auto", AutoSystemWfpBlockLeak: []string{"ipv4:tcp/853", "ipv6:udp/10-20", "ipv6:icmp"}}, + Output: &tun.Config{Name: "xray0", Desc: "Wintun", MTU: 1500, AutoSystemRoutingTable: []string{"0.0.0.0/0"}, AutoOutboundsInterface: "auto", AutoSystemWfpBlockLeak: []string{"1.1.1.0/24:tcp/853", "[2001:db8::/32]:udp/10-20", "[::/0]:icmp", "853"}}, }, { - Input: `{"name": "xray0", "dns": ["1.1.1.1"], "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["ipv4:dns", "IPv6:All", "ipv6:udp/53"]}`, + Input: `{"name": "xray0", "dns": ["1.1.1.1"], "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["0.0.0.0/0:dns", "[::/0]:All", "53"]}`, Parser: loadJSON(creator), - 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{"udp/53", "ipv4:tcp/53", "ipv4:windnsclient", "ipv4:tcp/853", "ipv4:udp/853", "ipv6:all"}}, + 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{"0.0.0.0/0:53", "0.0.0.0/0:853", "0.0.0.0/0:windnsclient", "[::/0]:all", "53"}}, }, }) } @@ -81,12 +81,14 @@ func TestTunConfigAutoSystemNeeds(t *testing.T) { {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["udp"]}`, []string{"windows"}}, {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["tcp/50-60"]}`, []string{"windows"}}, {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["tcp/54-60", "icmp"]}`, nil}, - {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["ipv6:udp/53"]}`, []string{"windows"}}, - {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["ipv4:dns"]}`, []string{"windows"}}, - {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["ipv6:all"]}`, []string{"windows"}}, - {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["ipv4:windnsclient"]}`, []string{"windows"}}, - {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["ipv4:tcp/853", "ipv4:icmp", "ipv6:misconfigtun"]}`, nil}, - {`{"name": "xray0", "autoSystemWfpBlockLeak": ["ipv6:misconfigtun"]}`, []string{"windows"}}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["53"]}`, []string{"windows"}}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["50-60"]}`, []string{"windows"}}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["[::/0]:udp/53"]}`, []string{"windows"}}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["1.1.1.1/32:dns"]}`, []string{"windows"}}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["[2001:db8::/32]:all"]}`, []string{"windows"}}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["0.0.0.0/0:windnsclient"]}`, []string{"windows"}}, + {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["0.0.0.0/0:tcp/853", "1.1.1.0/24:icmp", "[::/0]:misconfigtun", "54-60"]}`, nil}, + {`{"name": "xray0", "autoSystemWfpBlockLeak": ["[::/0]:misconfigtun"]}`, []string{"windows"}}, {`{"name": "xray0", "autoSystemWfpBlockLeak": ["all"]}`, []string{"windows"}}, {`{"name": "xray0", "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["allexceptlan"]}`, []string{"windows"}}, {`{"name": "xray0", "dns": ["1.1.1.1"], "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["allexceptlan"]}`, nil}, @@ -103,12 +105,21 @@ func TestTunConfigAutoSystemNeeds(t *testing.T) { } func TestTunConfigAutoSystemWfpBlockLeakUnknown(t *testing.T) { - for _, value := range []string{"ip", "tcp/0", "tcp/0-10", "icmp/1", "udp/20-10", "tcp4", "tcpv4", "ipv4", "ipv5:udp", "ipv4:icmp/1"} { + // With the settings the values need on Windows, so that only an unknown + // one fails. + build := func(value string) error { config := new(TunConfig) - if err := json.Unmarshal([]byte(`{"name": "xray0", "autoSystemWfpBlockLeak": ["dns", "`+value+`"]}`), config); err != nil { + if err := json.Unmarshal([]byte(`{"name": "xray0", "dns": ["1.1.1.1"], "autoSystemRoutingTable": ["0.0.0.0/0"], "autoSystemWfpBlockLeak": ["dns", "`+value+`"]}`), config); err != nil { t.Fatal(err) } - if _, err := config.Build(); err == nil { + _, err := config.Build() + return err + } + if err := build("1.1.1.0/24:53"); err != nil { + t.Fatal(err) + } + for _, value := range []string{"ip", "tcp/0", "tcp/0-10", "icmp/1", "udp/20-10", "0", "20-10", "53/tcp", "tcpv4", "ipv4:udp", "1.1.1.1:53", "2001:db8::/32:53", "[1.1.1.0/24]:53", "[::ffff:1.1.1.0/120]:53", "1.1.1.0/24"} { + if build(value) == nil { t.Errorf("autoSystemWfpBlockLeak value %q was accepted", value) } } diff --git a/proxy/tun/README.md b/proxy/tun/README.md index c4235bf26..6e6350277 100644 --- a/proxy/tun/README.md +++ b/proxy/tun/README.md @@ -228,19 +228,19 @@ With IPv6 disabled system-wide (the `DisabledComponents` registry value), the ad 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. Xray's own lookups through the system's DNS servers, like those of the `localhost` DNS server with `autoOutboundsInterface`, skip them, unless another interface uses them as well, as they would lead back into the TUN. Setting `autoSystemWfpBlockLeak` to at least `["dns"]` is recommended, to block DNS leaks. It needs `autoSystemRoutingTable` (the config is rejected otherwise), and adds Windows Filtering Platform filters that let traffic of every program but Xray itself leave only through the TUN, each kind chosen by a value: -- `"dns"`: stands for `"udp/53"`, `"tcp/53"`, `"windnsclient"`, `"tcp/853"` (DNS over TLS) and `"udp/853"` (DNS over QUIC). Windows also queries the DNS servers of the other interfaces, through those interfaces whatever the routes say, and other programs reach a resolver on the local network (e.g. `192.168.1.1` from DHCP) through its more specific route. +- `"dns"`: stands for `"53"`, `"853"` (DNS over TLS and QUIC) and `"windnsclient"`. Windows also queries the DNS servers of the other interfaces, through those interfaces whatever the routes say, and other programs reach a resolver on the local network (e.g. `192.168.1.1` from DHCP) through its more specific route. - `"windnsclient"`: Windows' DNS Client service, except for name resolution on the local network (LLMNR, mDNS). Since Windows 11 and Server 2022, it may query over HTTPS or TLS, to any port; earlier versions only query port 53, so there it does nothing. -- `"tcp"`, `"udp"`: that protocol, to all ports, or with `/` or `/-` (1 to 65535) to some, e.g. `"tcp/853"` or `"udp/10-20"`. +- `"tcp"`, `"udp"`: that protocol, to all ports, or with `/` or `/-` (1 to 65535) to some, e.g. `"tcp/853"` or `"udp/10-20"`. The ports alone stand for both, e.g. `"53"` or `"10-20"`. - `"icmp"`: ICMP and ICMPv6, e.g. `ping`. - `"misconfigtun"`: an IP version without routes in `autoSystemRoutingTable`, which would bypass the TUN. - `"all"`: everything, in both directions. Access to the local network then breaks unless it is routed into the TUN as well, and so do incoming connections to other programs, other VPNs, and virtual machines whose NAT runs on the host. - `"allexceptlan"`: like `"all"`, but traffic to and from the local network stays allowed, e.g. for printers or casting to a TV: private, link-local, multicast and broadcast addresses (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `224.0.0.0/4`, `255.255.255.255`, `fc00::/7`, `fe80::/10`, `ff00::/8`), not global IPv6 ones. It includes `"dns"`, and the other values apply there as well. `"all"` overrides it. -Any value can be limited to one IP version with `"ipv4:"` or `"ipv6:"` in front, e.g. `"ipv4:udp/53"` or `"ipv6:all"`. `"ipv4:allexceptlan"`, for instance, includes `"ipv4:dns"` only; add `"dns"` to block DNS over IPv6 as well. +A CIDR in front limits a value to its addresses, an IPv6 one in brackets, e.g. `"8.8.8.8/32:all"`, `"1.1.1.0/24:53"` or `"[::/0]:all"` (IPv6 only). It limits what the value stands for or includes as well: `"0.0.0.0/0:allexceptlan"`, for instance, leaves DNS over IPv6 open. -Loopback, DHCP, DHCPv6 and IPv6 neighbor discovery always stay allowed. Xray's own connections out also get past Windows Firewall's block rules, while connections to its inbounds stay subject to them. If the filters cannot be added, Xray does not start; they are removed when it exits. Name resolution on the local network (LLMNR, mDNS, NetBIOS) is not covered, except over an IP version that is blocked entirely. +Loopback, DHCP, DHCPv6 and IPv6 neighbor discovery always stay allowed. Xray's own connections out also get past Windows Firewall's block rules, while connections to its inbounds stay subject to them. If the filters cannot be added, Xray does not start; they are removed when it exits. `"dns"` leaves name resolution on the local network (LLMNR, mDNS, NetBIOS) alone; `"all"` or `"udp"`, for instance, block it as well. -Keeping the system's DNS inside the TUN, with `"all"`, `"allexceptlan"`, `"dns"`, `"windnsclient"`, or anything covering TCP or UDP port 53, even over one IP version only, needs `dns` (the config is rejected otherwise), whose servers have to lie within `gateway` or `autoSystemRoutingTable`, or be loopback addresses like `127.0.0.1` (a warning is logged otherwise); DNS servers to be reached directly belong in Xray's own `dns` settings. With `autoOutboundsInterface`, names that Xray would look up through the system, e.g. an outbound's server address with the default `AsIs` domain strategy, are then resolved by Xray itself, from the DNS servers of the other interfaces, as Windows would send those queries into the TUN. That bypasses Windows' DNS cache, so such a name is looked up for every connection; resolving it with Xray's own `dns`, through the outbound's `sockopt.domainStrategy`, avoids that. +Keeping the system's DNS inside the TUN, with `"all"`, `"allexceptlan"`, `"dns"`, `"windnsclient"`, or anything covering TCP or UDP port 53, even to some addresses only, needs `dns` (the config is rejected otherwise), whose servers have to lie within `gateway` or `autoSystemRoutingTable`, or be loopback addresses like `127.0.0.1` (a warning is logged otherwise); DNS servers to be reached directly belong in Xray's own `dns` settings. With `autoOutboundsInterface`, names that Xray would look up through the system, e.g. an outbound's server address with the default `AsIs` domain strategy, are then resolved by Xray itself, from the DNS servers of the other interfaces, as Windows would send those queries into the TUN. That bypasses Windows' DNS cache, so such a name is looked up for every connection; resolving it with Xray's own `dns`, through the outbound's `sockopt.domainStrategy`, avoids that. `autoSystemWfpBlockLeak` is empty by default, as the filters break some setups: with `"dns"`, a local DNS resolver that queries through another interface, the DNS of another VPN, virtual machines whose NAT resolves names on the host, and captive portals; with `"misconfigtun"`, IPv4 or IPv6 on the local network while not routed into the TUN. diff --git a/proxy/tun/tun_windows.go b/proxy/tun/tun_windows.go index f188575a3..e84ebbb65 100644 --- a/proxy/tun/tun_windows.go +++ b/proxy/tun/tun_windows.go @@ -250,17 +250,19 @@ startOver: // keep the protocols and ports given ("dns" stands for those of DNS), // Windows' DNS Client service, an IP version no route of which leads to // the TUN ("misconfigtun"), or everything ("all", or "allexceptlan" but - // for the local network), over IPv4, IPv6 or both, from leaving through - // the other interfaces. Addresses do not matter: without one of a version - // in gateway, Windows gives the TUN a link-local one. + // for the local network), to the addresses of a CIDR or to all, from + // leaving through the other interfaces. The TUN's own addresses do not + // matter: without one of a version in gateway, Windows gives it a + // link-local one. leaks, err := ExpandWfpBlockLeak(t.options.AutoSystemWfpBlockLeak) if err != nil { return err } block := newLeakFilters(leaks, route4, route6) if route4 || route6 { - if block.dnsClient != 0 && !dnsClientUsesAnyPort() { - block.dnsClient = 0 + dnsClient := func(l leak) bool { return l.name == "windnsclient" } + if slices.ContainsFunc(block, dnsClient) && !dnsClientUsesAnyPort() { + block = slices.DeleteFunc(block, dnsClient) errors.LogInfo(context.Background(), "[tun] windnsclient skipped: before Windows 11 and Server 2022, the DNS Client service only queries port 53") } if block.systemDNS() && len(dns) == 0 { @@ -273,19 +275,17 @@ startOver: return errors.New("unable to block ", blocked, " outside the TUN (remove autoSystemWfpBlockLeak to run without)").Base(err) } errors.LogInfo(context.Background(), "[tun] blocked outside the TUN: ", blocked) - if block.systemDNS() { - 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() - } + covered := slices.Clone(addresses) + for _, route := range routesData { + covered = append(covered, route.Destination) + } + for _, server := range dnsOutsideTUN(dns, covered, block) { + 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 block.systemDNS() && updater != nil { + t.resolver = resolveOnOwn() } } if len(dns) > 0 || route4 || route6 { diff --git a/proxy/tun/tun_windows_wfp.go b/proxy/tun/tun_windows_wfp.go index 52bf503ca..86278c851 100644 --- a/proxy/tun/tun_windows_wfp.go +++ b/proxy/tun/tun_windows_wfp.go @@ -207,79 +207,106 @@ func condition(field *windows.GUID, typ uint32, value uintptr) fwpmFilterConditi } } -// leakFilters is what blockLeaks keeps from leaving outside the TUN, each over -// the IP versions given. -type leakFilters struct { - traffic []leak // connections of these protocols and ports - dnsClient ipVersions // Windows' DNS Client service - all ipVersions // everything ("all" or "allexceptlan") - lan ipVersions // but to and from the local network ("allexceptlan") - unrouted ipVersions // everything, as no route leads to the TUN ("misconfigtun") -} +// leakFilters are the values of autoSystemWfpBlockLeak that blockLeaks keeps +// from leaving outside the TUN. +type leakFilters []leak // newLeakFilters turns expanded autoSystemWfpBlockLeak values into the filters -// for a TUN that the routes of route4 (IPv4) and route6 (IPv6) lead to. +// for a TUN that the routes of route4 (IPv4) and route6 (IPv6) lead to, which +// leave "misconfigtun" over the IP versions without such routes only. func newLeakFilters(leaks []string, route4, route6 bool) leakFilters { var block leakFilters - var routed, allExceptLAN ipVersions - if route4 { - routed |= ipv4Version - } - if route6 { - routed |= ipv6Version - } for _, value := range leaks { - switch l, _ := parseLeak(value); l.name { - case "windnsclient": - block.dnsClient |= l.versions - case "misconfigtun": - block.unrouted |= l.versions &^ routed - case "all": - block.all |= l.versions - case "allexceptlan": - allExceptLAN |= l.versions - case "tcp", "udp", "icmp": - block.traffic = append(block.traffic, l) + l, ok := parseLeak(value) + if !ok { + continue + } + if l.name == "misconfigtun" { + unrouted4, unrouted6 := !route4, !route6 + if l.prefix.IsValid() { + unrouted4, unrouted6 = unrouted4 && l.prefix.Addr().Is4(), unrouted6 && l.prefix.Addr().Is6() + } + switch { + case !unrouted4 && !unrouted6: + continue + case !l.prefix.IsValid() && !unrouted4: + l.prefix = allAddresses(true) + case !l.prefix.IsValid() && !unrouted6: + l.prefix = allAddresses(false) + } + } + if !slices.Contains(block, l) { + block = append(block, l) } } - // "all" overrides "allexceptlan". - block.lan = allExceptLAN &^ block.all - block.all |= allExceptLAN return block } // String lists what block keeps inside the TUN, or is empty for nothing. func (block leakFilters) String() string { - var blocked []string - for _, traffic := range block.traffic { - blocked = append(blocked, traffic.String()) + values := make([]string, len(block)) + for i, l := range block { + values[i] = l.String() } - if block.dnsClient != 0 { - blocked = append(blocked, leak{versions: block.dnsClient, name: "windnsclient"}.String()) - } - for _, family := range []struct { - version ipVersions - name string - }{{ipv4Version, "IPv4"}, {ipv6Version, "IPv6"}} { - if (block.all|block.unrouted)&family.version == 0 { - continue - } - if block.lan&family.version != 0 { - family.name += " except LAN" - } - blocked = append(blocked, family.name) - } - return strings.Join(blocked, ", ") + return strings.Join(values, ", ") } -// systemDNS tells whether block keeps the system's DNS inside the TUN, over at -// least one IP version: port 53, the DNS Client service, or everything ("all"), -// though not with the local network left open, where a resolver like the -// router's may be. +// systemDNS tells whether block keeps the system's DNS inside the TUN, to at +// least some addresses: port 53, the DNS Client service, or everything ("all"), +// though not everything but the local network ("allexceptlan"), where a +// resolver like the router's may be. func (block leakFilters) systemDNS() bool { - return block.dnsClient != 0 || block.all&^block.lan != 0 || slices.ContainsFunc(block.traffic, func(traffic leak) bool { - return traffic.covers("udp", 53) || traffic.covers("tcp", 53) - }) + return slices.ContainsFunc(block, leak.blocksDNS) +} + +// prefixes returns the CIDRs of the values of block named name over an IP +// version. +func (block leakFilters) prefixes(name string, ipv6 bool) []netip.Prefix { + var prefixes []netip.Prefix + for _, l := range block { + if prefix, ok := l.over(ipv6); ok && l.name == name { + prefixes = append(prefixes, prefix) + } + } + return prefixes +} + +// over returns the CIDR of l over an IP version, all its addresses for l +// without one, or false for l over the other IP version only. +func (l leak) over(ipv6 bool) (netip.Prefix, bool) { + if !l.prefix.IsValid() { + return allAddresses(ipv6), true + } + return l.prefix, l.prefix.Addr().Is6() == ipv6 +} + +// allAddresses is the CIDR of all addresses of an IP version. +func allAddresses(ipv6 bool) netip.Prefix { + if ipv6 { + return netip.PrefixFrom(netip.IPv6Unspecified(), 0) + } + return netip.PrefixFrom(netip.IPv4Unspecified(), 0) +} + +// lanWithin returns the parts of lanPrefixes that lie in any of prefixes: two +// prefixes either share no address, or one holds the other. +func lanWithin(prefixes ...netip.Prefix) []netip.Prefix { + var parts []netip.Prefix + for _, lan := range lanPrefixes { + for _, prefix := range prefixes { + if !prefix.Overlaps(lan) { + continue + } + part := lan + if prefix.Bits() > lan.Bits() { + part = prefix + } + if !slices.Contains(parts, part) { + parts = append(parts, part) + } + } + } + return parts } // dnsClientUsesAnyPort tells whether Windows' DNS Client service may send its @@ -292,11 +319,24 @@ func dnsClientUsesAnyPort() bool { return build >= 20348 } +// The weights of the filters of blockLeaks, the heaviest matching one of which +// decides. +const ( + weightAllExceptLAN uint8 = iota // block "allexceptlan" + weightLAN // permit its local network + weightDNSClient // block "windnsclient" + weightLocalLink // permit its mDNS and LLMNR on the local network + weightBlock // block the other values + weightPermit // permit what always stays allowed, and the TUN + weightXray // permit Xray +) + // blockLeaks keeps traffic from leaving through interfaces other than tun, // for every program but Xray itself, whose outbounds (DNS included) use the -// other interfaces on purpose, each over the IP versions block gives: +// other interfaces on purpose, as the values of block say, each to the +// addresses of its CIDR, or to all: // -// - traffic: connections of these protocols, TCP and UDP to some or all +// - Protocols and ports: connections of these, TCP or UDP to some or all // ports, or ICMP, may only go through the TUN, those of DNS (53), DNS // over TLS (TCP 853) and DNS over QUIC (UDP 853) with "dns". Windows // sends a name query to the DNS servers of all interfaces, not only to @@ -306,22 +346,22 @@ func dnsClientUsesAnyPort() bool { // whatever the routes say, and other programs reach an on-link resolver, // like 192.168.1.1 from DHCP, through its LAN route, which is more // specific than the TUN's default route. -// - dnsClient: since Windows 11 and Server 2022, Windows may also send its -// queries over HTTPS or TLS, to any port, so its DNS Client service may not -// connect outside the TUN at all, except for name resolution on the local -// link (mDNS, LLMNR). -// - all, unrouted: nothing at all outside the TUN, in either direction: -// with "all", or with "allexceptlan" (lan) but to and from the local -// network, below the other filters, which so still apply there, or with -// "misconfigtun" for an IP version no route of which leads to the TUN. -// The TUN carries what is routed to it even without an address of that -// IP version in gateway: Windows gives it link-local ones itself, an IPv6 +// - "windnsclient": since Windows 11 and Server 2022, Windows may also send +// its queries over HTTPS or TLS, to any port, so its DNS Client service +// may not connect outside the TUN at all, except for name resolution on +// the local network (mDNS, LLMNR). +// - "all", and "misconfigtun" over an IP version no route of which leads to +// the TUN: nothing at all outside the TUN, in either direction. The TUN +// carries what is routed to it even without an address of that IP +// version in gateway: Windows gives it link-local ones itself, an IPv6 // one at once, an IPv4 one from 169.254.0.0/16 after some seconds (until // then, IPv4 routed to the TUN is unreachable). +// - "allexceptlan": the same, but to and from the local network. // -// Whatever is blocked, loopback, which cannot leave the system, and what -// Windows itself needs on the local link stay allowed: DHCP, and for IPv6 -// neighbor and multicast listener discovery. +// The other values still apply to what "allexceptlan" and "windnsclient" leave +// open on the local network. Whatever is blocked, loopback, which cannot leave +// the system, and what Windows itself needs on the local link stay allowed: +// DHCP, and for IPv6 neighbor and multicast listener discovery. // // The filters live in a dynamic WFP session: closing the returned engine handle // with closeWFPEngine deletes them, and so does Windows when the process dies. @@ -422,17 +462,18 @@ func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, block leakFilters) onTUN := func(field *windows.GUID) fwpmFilterCondition0 { return condition(field, fwpUint64, uintptr(unsafe.Pointer(tunLUID))) } - // toLAN matches the remote addresses of lanPrefixes of an IP version: - // conditions on the same field match when any of them does. - toLAN := func(ipv6 bool) []fwpmFilterCondition0 { + // remote matches the remote addresses in any of prefixes, all of one IP + // version: conditions on the same field match when any of them does. With + // all addresses among them, no condition is needed. + remote := func(prefixes ...netip.Prefix) []fwpmFilterCondition0 { + if slices.ContainsFunc(prefixes, func(prefix netip.Prefix) bool { return prefix.Bits() == 0 }) { + return nil + } var conditions []fwpmFilterCondition0 - for _, prefix := range lanPrefixes { - if prefix.Addr().Is6() != ipv6 { - continue - } + for _, prefix := range prefixes { var value uintptr typ := uint32(fwpV4AddrMask) - if ipv6 { + if prefix.Addr().Is6() { addr := &fwpV6AddrAndMask{addr: prefix.Addr().As16(), prefixLength: uint8(prefix.Bits())} pinner.Pin(addr) // the condition only holds it as uintptr value, typ = uintptr(unsafe.Pointer(addr)), fwpV6AddrMask @@ -506,60 +547,64 @@ func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, block leakFilters) key *windows.GUID selfFlags uint32 via *windows.GUID // the interface that has to be the TUN as well - version ipVersions + ipv6 bool permits []permit }{ - {&fwpmLayerALEAuthConnectV4, fwpmFilterFlagClearActionRight, &fwpmConditionIPNexthopInterface, ipv4Version, []permit{loopback, dhcp}}, - {&fwpmLayerALEAuthRecvAcceptV4, 0, &fwpmConditionIPArrivalInterface, ipv4Version, []permit{loopback, dhcp}}, - {&fwpmLayerALEAuthConnectV6, fwpmFilterFlagClearActionRight, &fwpmConditionIPNexthopInterface, ipv6Version, []permit{loopback, discovery, dhcpv6Out}}, - {&fwpmLayerALEAuthRecvAcceptV6, 0, &fwpmConditionIPArrivalInterface, ipv6Version, []permit{loopback, discovery, dhcpv6In}}, + {&fwpmLayerALEAuthConnectV4, fwpmFilterFlagClearActionRight, &fwpmConditionIPNexthopInterface, false, []permit{loopback, dhcp}}, + {&fwpmLayerALEAuthRecvAcceptV4, 0, &fwpmConditionIPArrivalInterface, false, []permit{loopback, dhcp}}, + {&fwpmLayerALEAuthConnectV6, fwpmFilterFlagClearActionRight, &fwpmConditionIPNexthopInterface, true, []permit{loopback, discovery, dhcpv6Out}}, + {&fwpmLayerALEAuthRecvAcceptV6, 0, &fwpmConditionIPArrivalInterface, true, []permit{loopback, discovery, dhcpv6In}}, } { - ipv6 := layer.version == ipv6Version - throughTUN := []fwpmFilterCondition0{onTUN(&fwpmConditionIPLocalInterface), onTUN(layer.via)} - if err := add(layer.key, "permit Xray", layer.selfFlags, fwpActionPermit, 4, self); err != nil { + throughTUN := permit{"through the TUN", []fwpmFilterCondition0{onTUN(&fwpmConditionIPLocalInterface), onTUN(layer.via)}} + if err := add(layer.key, "permit Xray", layer.selfFlags, fwpActionPermit, weightXray, self); err != nil { return err } - for _, p := range layer.permits { - if err := add(layer.key, "permit "+p.name, 0, fwpActionPermit, 3, p.conditions...); err != nil { + for _, p := range append(layer.permits, throughTUN) { + if err := add(layer.key, "permit "+p.name, 0, fwpActionPermit, weightPermit, p.conditions...); err != nil { return err } } - for _, traffic := range block.traffic { - if traffic.versions&layer.version == 0 { + for _, l := range block { + prefix, ok := l.over(layer.ipv6) + if !ok { continue // for the other IP version only } - protocol := uintptr(windows.IPPROTO_UDP) - switch { - case traffic.name == "tcp": - protocol = windows.IPPROTO_TCP - case traffic.name == "icmp" && ipv6: - protocol = windows.IPPROTO_ICMPV6 - case traffic.name == "icmp": - protocol = windows.IPPROTO_ICMP + var protocols []uintptr + switch l.name { + case "tcp": + protocols = []uintptr{windows.IPPROTO_TCP} + case "udp": + protocols = []uintptr{windows.IPPROTO_UDP} + case "": + protocols = []uintptr{windows.IPPROTO_TCP, windows.IPPROTO_UDP} + case "icmp": + protocols = []uintptr{windows.IPPROTO_ICMP} + if layer.ipv6 { + protocols = []uintptr{windows.IPPROTO_ICMPV6} + } + case "all", "misconfigtun": + // every protocol + default: + continue // "allexceptlan" and "windnsclient" have filters of their own } - matches := slices.Concat([]fwpmFilterCondition0{condition(&fwpmConditionIPProtocol, fwpUint8, protocol)}, toPorts(traffic)) - if err := add(layer.key, "permit "+traffic.String()+" through the TUN", 0, fwpActionPermit, 3, slices.Concat(matches, throughTUN)...); err != nil { - return err + var matches []fwpmFilterCondition0 + for _, protocol := range protocols { + matches = append(matches, condition(&fwpmConditionIPProtocol, fwpUint8, protocol)) } - if err := add(layer.key, "block "+traffic.String(), 0, fwpActionBlock, 2, matches...); err != nil { + matches = slices.Concat(matches, toPorts(l), remote(prefix)) + if err := add(layer.key, "block "+l.String(), 0, fwpActionBlock, weightBlock, matches...); err != nil { return err } } - if family := "IPv4"; (block.all|block.unrouted)&layer.version != 0 { - if ipv6 { - family = "IPv6" - } - if err := add(layer.key, "permit "+family+" through the TUN", 0, fwpActionPermit, 1, throughTUN...); err != nil { - return err - } - // Below the other values, which so still apply on the local - // network, DNS with "allexceptlan" among them. - if block.lan&layer.version != 0 { - if err := add(layer.key, "permit "+family+" LAN", 0, fwpActionPermit, 1, toLAN(ipv6)...); err != nil { + if allExceptLAN := block.prefixes("allexceptlan", layer.ipv6); len(allExceptLAN) > 0 { + // Lighter than the other values, which so still apply on the + // local network, DNS with "allexceptlan" among them. + if lan := lanWithin(allExceptLAN...); len(lan) > 0 { + if err := add(layer.key, "permit the local network", 0, fwpActionPermit, weightLAN, remote(lan...)...); err != nil { return err } } - if err := add(layer.key, "block "+family, 0, fwpActionBlock, 0); err != nil { + if err := add(layer.key, "block allexceptlan", 0, fwpActionBlock, weightAllExceptLAN, remote(allExceptLAN...)...); err != nil { return err } } @@ -568,13 +613,11 @@ func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, block leakFilters) // Since Windows 11 and Server 2022, the DNS Client service may also send // the queries for an interface's servers over HTTPS or TLS, out through // that interface and to any port. So there it may only connect through - // the TUN, except for mDNS and LLMNR, which stay on the local link (over - // an IP version only while it is not blocked altogether, or with - // "allexceptlan" to the local network's addresses). Like Windows + // the TUN, except for mDNS and LLMNR on the local network. Like Windows // Firewall's rules for it, the filters recognize the service by its SID, // which Windows puts in the token of its process: the security descriptor // grants that SID the right to match (FWP_ACTRL_MATCH_FILTER, CC in SDDL). - if block.dnsClient != 0 { + if slices.ContainsFunc(block, func(l leak) bool { return l.name == "windnsclient" }) { sd, err := windows.SecurityDescriptorFromString("O:SYG:SYD:(A;;CCRC;;;" + dnsClientSID + ")") if err != nil { return err @@ -585,29 +628,20 @@ func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, block leakFilters) // Conditions on the same field match when any of them does. mdnsLLMNR := []fwpmFilterCondition0{dnsClient, condition(&fwpmConditionIPRemotePort, fwpUint16, 5353), condition(&fwpmConditionIPRemotePort, fwpUint16, 5355)} for _, layer := range []struct { - key *windows.GUID - version ipVersions + key *windows.GUID + ipv6 bool }{ - {&fwpmLayerALEAuthConnectV4, ipv4Version}, - {&fwpmLayerALEAuthConnectV6, ipv6Version}, + {&fwpmLayerALEAuthConnectV4, false}, + {&fwpmLayerALEAuthConnectV6, true}, } { - if block.dnsClient&layer.version == 0 { + prefixes := block.prefixes("windnsclient", layer.ipv6) + if len(prefixes) == 0 { continue } - if err := add(layer.key, "permit the DNS Client service through the TUN", 0, fwpActionPermit, 3, dnsClient, onTUN(&fwpmConditionIPLocalInterface), onTUN(&fwpmConditionIPNexthopInterface)); err != nil { + if err := add(layer.key, "permit the DNS Client service's mDNS and LLMNR", 0, fwpActionPermit, weightLocalLink, slices.Concat(mdnsLLMNR, remote(lanWithin(allAddresses(layer.ipv6))...))...); err != nil { return err } - lan := block.lan&layer.version != 0 - if (block.all|block.unrouted)&layer.version == 0 || lan { - localLink := mdnsLLMNR - if lan { - localLink = slices.Concat(mdnsLLMNR, toLAN(layer.version == ipv6Version)) - } - if err := add(layer.key, "permit the DNS Client service's mDNS and LLMNR", 0, fwpActionPermit, 3, localLink...); err != nil { - return err - } - } - if err := add(layer.key, "block the DNS Client service", 0, fwpActionBlock, 2, dnsClient); err != nil { + if err := add(layer.key, "block the DNS Client service", 0, fwpActionBlock, weightDNSClient, slices.Concat([]fwpmFilterCondition0{dnsClient}, remote(prefixes...))...); err != nil { return err } } @@ -634,15 +668,22 @@ func addFilter(engine windows.Handle, sublayer, layer *windows.GUID, name string return nil } -// dnsOutsideTUN returns the servers whose queries the filters block: those -// outside all of prefixes, the TUN's own subnets and routes, as queries to -// them cannot go through the TUN. Loopback ones, local resolvers, are not -// among them, as loopback stays allowed. -func dnsOutsideTUN(servers []netip.Addr, prefixes []netip.Prefix) []netip.Addr { +// dnsOutsideTUN returns the servers whose queries block blocks: those outside +// all of prefixes, the TUN's own subnets and routes, as queries to them cannot +// go through the TUN, and inside the CIDR of a value that keeps DNS inside the +// TUN, or of "misconfigtun". Loopback ones, local resolvers, are not among +// them, as loopback stays allowed. +func dnsOutsideTUN(servers []netip.Addr, prefixes []netip.Prefix, block leakFilters) []netip.Addr { var outside []netip.Addr for _, server := range servers { server = server.Unmap() - if !server.IsLoopback() && !slices.ContainsFunc(prefixes, func(p netip.Prefix) bool { return p.Contains(server) }) { + if server.IsLoopback() || slices.ContainsFunc(prefixes, func(p netip.Prefix) bool { return p.Contains(server) }) { + continue + } + if slices.ContainsFunc(block, func(l leak) bool { + prefix, ok := l.over(server.Is6()) + return ok && prefix.Contains(server) && (l.blocksDNS() || l.name == "misconfigtun") + }) { outside = append(outside, server) } } diff --git a/proxy/tun/tun_windows_wfp_test.go b/proxy/tun/tun_windows_wfp_test.go index c0e8f7b9f..47c6f700c 100644 --- a/proxy/tun/tun_windows_wfp_test.go +++ b/proxy/tun/tun_windows_wfp_test.go @@ -134,11 +134,12 @@ func TestLeakFiltersAccepted(t *testing.T) { values []string route4, route6 bool }{ - {[]string{"dns", "misconfigtun", "all", "tcp/443", "udp/1000-2000", "tcp", "icmp", "ipv4:tcp/80", "ipv6:udp/5000-6000", "ipv6:icmp"}, false, false}, - // The local network's addresses. - {[]string{"allexceptlan", "udp/1000-2000", "icmp"}, true, false}, - {[]string{"ipv4:allexceptlan", "ipv6:all", "ipv6:windnsclient"}, true, true}, - {[]string{"ipv6:allexceptlan", "ipv4:misconfigtun"}, false, true}, + {[]string{"dns", "misconfigtun", "all", "tcp/443", "udp/1000-2000", "tcp", "icmp", "10-20", "1-65535"}, false, false}, + // CIDRs, and the local network's addresses. + {[]string{"1.1.1.0/24:tcp/80", "[2001:db8::/32]:udp/5000-6000", "[::/0]:icmp", "8.8.8.8/32:all", "[2000::/3]:misconfigtun", "9.9.9.9/32:dns"}, true, false}, + {[]string{"allexceptlan", "udp/1000-2000", "icmp", "192.168.1.5/32:all"}, true, false}, + {[]string{"0.0.0.0/0:allexceptlan", "[::/0]:all", "[::/0]:windnsclient", "1.0.0.0/8:windnsclient"}, true, true}, + {[]string{"[::/0]:allexceptlan", "0.0.0.0/0:misconfigtun", "10.0.0.0/8:allexceptlan", "1.0.0.0/8:allexceptlan"}, false, true}, } { leaks, err := ExpandWfpBlockLeak(c.values) if err != nil { @@ -157,28 +158,30 @@ func TestNewLeakFilters(t *testing.T) { blocked string systemDNS bool }{ - {[]string{"dns", "misconfigtun"}, true, false, "udp/53, tcp/53, tcp/853, udp/853, windnsclient, IPv6", true}, + {[]string{"dns", "misconfigtun"}, true, false, "53, 853, windnsclient, [::/0]:misconfigtun", true}, {[]string{"tcp/853", "udp/853", "misconfigtun"}, true, true, "tcp/853, udp/853", false}, - {[]string{"all"}, true, true, "IPv4, IPv6", true}, - {[]string{"all", "misconfigtun"}, true, true, "IPv4, IPv6", true}, - {[]string{"misconfigtun"}, true, false, "IPv6", false}, + {[]string{"all"}, true, true, "all", true}, + {[]string{"all", "misconfigtun"}, true, true, "all", true}, + {[]string{"misconfigtun"}, true, false, "[::/0]:misconfigtun", false}, + {[]string{"misconfigtun"}, false, true, "0.0.0.0/0:misconfigtun", false}, + {[]string{"misconfigtun"}, false, false, "misconfigtun", false}, {[]string{"tcp/10-20", "udp", "icmp"}, true, true, "tcp/10-20, udp, icmp", true}, - {[]string{"tcp/54-60", "icmp"}, true, true, "tcp/54-60, icmp", false}, - {[]string{"allexceptlan"}, true, true, "udp/53, tcp/53, tcp/853, udp/853, windnsclient, IPv4 except LAN, IPv6 except LAN", true}, - {[]string{"allexceptlan", "all"}, true, true, "udp/53, tcp/53, tcp/853, udp/853, windnsclient, IPv4, IPv6", true}, - {[]string{"misconfigtun", "allexceptlan"}, true, false, "udp/53, tcp/53, tcp/853, udp/853, windnsclient, IPv4 except LAN, IPv6 except LAN", true}, - // For one IP version only. - {[]string{"IPv4:TCP/10-20", "ipv6:udp", "ipv4:icmp"}, true, true, "ipv4:tcp/10-20, ipv6:udp, ipv4:icmp", true}, - {[]string{"ipv6:tcp/54-60", "ipv6:icmp"}, true, true, "ipv6:tcp/54-60, ipv6:icmp", false}, - {[]string{"ipv4:windnsclient"}, true, true, "ipv4:windnsclient", true}, - {[]string{"ipv6:all"}, true, true, "IPv6", true}, - {[]string{"ipv6:all"}, true, false, "IPv6", true}, - {[]string{"ipv4:allexceptlan", "ipv6:all"}, true, true, "ipv4:udp/53, ipv4:tcp/53, ipv4:tcp/853, ipv4:udp/853, ipv4:windnsclient, IPv4 except LAN, IPv6", true}, - {[]string{"allexceptlan", "ipv6:all"}, true, true, "udp/53, tcp/53, tcp/853, udp/853, windnsclient, IPv4 except LAN, IPv6", true}, - {[]string{"ipv4:misconfigtun"}, true, false, "", false}, - {[]string{"ipv6:misconfigtun"}, true, false, "IPv6", false}, - {[]string{"ipv4:misconfigtun", "ipv6:allexceptlan"}, false, true, "ipv6:udp/53, ipv6:tcp/53, ipv6:tcp/853, ipv6:udp/853, ipv6:windnsclient, IPv4, IPv6 except LAN", true}, - {[]string{"ipv6:windnsclient", "ipv4:windnsclient"}, true, true, "windnsclient", true}, + {[]string{"tcp/54-60", "icmp", "54-60"}, true, true, "tcp/54-60, icmp, 54-60", false}, + {[]string{"allexceptlan"}, true, true, "allexceptlan, 53, 853, windnsclient", true}, + {[]string{"allexceptlan", "all"}, true, true, "allexceptlan, 53, 853, windnsclient, all", true}, + {[]string{"misconfigtun", "allexceptlan"}, true, false, "[::/0]:misconfigtun, allexceptlan, 53, 853, windnsclient", true}, + // Limited to the addresses of a CIDR. + {[]string{"0.0.0.0/0:TCP/10-20", "[::/0]:udp", "0.0.0.0/0:icmp"}, true, true, "0.0.0.0/0:tcp/10-20, [::/0]:udp, 0.0.0.0/0:icmp", true}, + {[]string{"[::/0]:tcp/54-60", "[::/0]:icmp"}, true, true, "[::/0]:tcp/54-60, [::/0]:icmp", false}, + {[]string{"0.0.0.0/0:windnsclient"}, true, true, "0.0.0.0/0:windnsclient", true}, + {[]string{"1.1.1.0/24:53"}, true, true, "1.1.1.0/24:53", true}, + {[]string{"[::/0]:all"}, true, false, "[::/0]:all", true}, + {[]string{"0.0.0.0/0:allexceptlan", "[::/0]:all"}, true, true, "0.0.0.0/0:allexceptlan, 0.0.0.0/0:53, 0.0.0.0/0:853, 0.0.0.0/0:windnsclient, [::/0]:all", true}, + {[]string{"0.0.0.0/0:misconfigtun"}, true, false, "", false}, + {[]string{"[::/0]:misconfigtun"}, true, false, "[::/0]:misconfigtun", false}, + {[]string{"[2000::/3]:misconfigtun", "1.0.0.0/8:misconfigtun"}, true, false, "[2000::/3]:misconfigtun", false}, + {[]string{"misconfigtun", "[::/0]:misconfigtun"}, true, false, "[::/0]:misconfigtun", false}, + {[]string{"0.0.0.0/0:misconfigtun", "[::/0]:allexceptlan"}, false, true, "0.0.0.0/0:misconfigtun, [::/0]:allexceptlan, [::/0]:53, [::/0]:853, [::/0]:windnsclient", true}, } { leaks, err := ExpandWfpBlockLeak(c.values) if err != nil { @@ -194,11 +197,36 @@ func TestNewLeakFilters(t *testing.T) { } // Without the port 53 values "allexceptlan" brings along, a resolver on // the local network, like the router's, stays reachable. - if (leakFilters{all: bothIPVersions, lan: bothIPVersions}).systemDNS() { + if (leakFilters{{name: "allexceptlan"}}).systemDNS() { t.Error("systemDNS() = true with the local network open") } } +func TestLanWithin(t *testing.T) { + prefixes := func(cidrs ...string) []netip.Prefix { + var prefixes []netip.Prefix + for _, cidr := range cidrs { + prefixes = append(prefixes, netip.MustParsePrefix(cidr)) + } + return prefixes + } + for _, c := range []struct { + within, lan []netip.Prefix + }{ + {nil, nil}, + {prefixes("0.0.0.0/0"), prefixes("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "224.0.0.0/4", "255.255.255.255/32")}, + {prefixes("::/0"), prefixes("fc00::/7", "fe80::/10", "ff00::/8")}, + {prefixes("1.0.0.0/8", "10.1.0.0/16"), prefixes("10.1.0.0/16")}, + {prefixes("192.0.0.0/2"), prefixes("192.168.0.0/16", "224.0.0.0/4", "255.255.255.255/32")}, + {prefixes("2000::/3", "fe80::/64", "fe80::/10"), prefixes("fe80::/64", "fe80::/10")}, + {prefixes("8.8.8.8/32", "2001:db8::/32"), nil}, + } { + if got := lanWithin(c.within...); !slices.Equal(got, c.lan) { + t.Errorf("%v: got %v, want %v", c.within, got, c.lan) + } + } +} + func TestDNSClientSID(t *testing.T) { sid, _, _, err := windows.LookupSID("", `NT SERVICE\Dnscache`) if err != nil { @@ -225,9 +253,33 @@ func TestDNSOutsideTUN(t *testing.T) { netip.MustParseAddr("::ffff:127.0.0.53"), netip.MustParseAddr("::1"), } - want := []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("2001:db8::53")} - if got := dnsOutsideTUN(servers, prefixes); !slices.Equal(got, want) { - t.Errorf("got %v, want %v", got, want) + // Only to the addresses of their CIDRs, if any, and "misconfigtun" over + // an IP version without routes to the TUN blocks DNS as well. + for _, c := range []struct { + values []string + route4, route6 bool + want []string + }{ + {[]string{"dns"}, true, true, []string{"8.8.8.8", "2001:db8::53"}}, + {[]string{"0.0.0.0/0:53"}, true, true, []string{"8.8.8.8"}}, + {[]string{"8.8.8.0/24:windnsclient", "[2001:db8::/32]:tcp/853"}, true, true, []string{"8.8.8.8"}}, + {[]string{"[2001:db8::53/128]:all", "9.9.9.9/32:53"}, true, true, []string{"2001:db8::53"}}, + {[]string{"misconfigtun"}, true, false, []string{"2001:db8::53"}}, + {[]string{"misconfigtun"}, true, true, nil}, + {[]string{"tcp/853", "udp/5353", "icmp", "10.0.0.0/8:all"}, true, true, nil}, + {[]string{"[::/0]:allexceptlan"}, true, true, []string{"2001:db8::53"}}, + } { + leaks, err := ExpandWfpBlockLeak(c.values) + if err != nil { + t.Fatal(err) + } + var want []netip.Addr + for _, server := range c.want { + want = append(want, netip.MustParseAddr(server)) + } + if got := dnsOutsideTUN(servers, prefixes, newLeakFilters(leaks, c.route4, c.route6)); !slices.Equal(got, want) { + t.Errorf("%v: got %v, want %v", c.values, got, want) + } } } diff --git a/proxy/tun/wfp_leak.go b/proxy/tun/wfp_leak.go index 399fdc05d..2efa2e68d 100644 --- a/proxy/tun/wfp_leak.go +++ b/proxy/tun/wfp_leak.go @@ -1,6 +1,7 @@ package tun import ( + "net/netip" "slices" "strconv" "strings" @@ -8,82 +9,96 @@ import ( "github.com/xtls/xray-core/common/errors" ) -// dnsLeaks is what "dns" in autoSystemWfpBlockLeak stands for: DNS over UDP and -// TCP, Windows' DNS Client service, which may also use other protocols and -// ports, DNS over TLS, and DNS over QUIC. -var dnsLeaks = []string{"udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853"} - -// ipVersions is a set of IP versions. -type ipVersions uint8 - -const ( - ipv4Version ipVersions = 1 << iota - ipv6Version - bothIPVersions = ipv4Version | ipv6Version -) +// dnsLeaks is what "dns" in autoSystemWfpBlockLeak stands for: DNS over TCP +// and UDP, DNS over TLS and QUIC, and Windows' DNS Client service, which may +// also use other protocols and ports. +var dnsLeaks = []string{"53", "853", "windnsclient"} // leak is a value of autoSystemWfpBlockLeak: "dns", "windnsclient", // "misconfigtun", "all", "allexceptlan", "icmp", or "tcp" or "udp", the latter // two with "/" or "/-" for some ports only, such as "tcp/853" -// or "udp/10-20", and any of them after "ipv4:" or "ipv6:" for one IP version -// only, such as "ipv4:udp/53" or "ipv6:all". +// or "udp/10-20", or such ports alone for TCP and UDP, such as "53". A CIDR in +// front limits any of them to its addresses, an IPv6 one in brackets, such as +// "1.1.1.0/24:53" or "[::/0]:all". type leak struct { - versions ipVersions - name string - low, high uint16 // the ports of "tcp" and "udp", both 0 for all + prefix netip.Prefix // all addresses of both IP versions if not valid + name string // empty for TCP and UDP to some ports + low, high uint16 // the ports of TCP and UDP, both 0 for all } func (l leak) String() string { s := l.name - switch l.versions { - case ipv4Version: - s = "ipv4:" + s - case ipv6Version: - s = "ipv6:" + s + if l.low != 0 { + if s != "" { + s += "/" + } + s += strconv.Itoa(int(l.low)) + if l.high != l.low { + s += "-" + strconv.Itoa(int(l.high)) + } } switch { - case l.low == 0: + case !l.prefix.IsValid(): return s - case l.low == l.high: - return s + "/" + strconv.Itoa(int(l.low)) + case l.prefix.Addr().Is6(): + return "[" + l.prefix.String() + "]:" + s default: - return s + "/" + strconv.Itoa(int(l.low)) + "-" + strconv.Itoa(int(l.high)) + return l.prefix.String() + ":" + s } } -// covers tells whether l includes connections of protocol to port, over at -// least one IP version. +// covers tells whether l includes connections of protocol, "tcp" or "udp", to +// port, to at least some addresses. func (l leak) covers(protocol string, port uint16) bool { - return l.name == protocol && (l.low == 0 || l.low <= port && port <= l.high) + switch l.name { + case "all": + return true + case "", protocol: + return l.low == 0 || l.low <= port && port <= l.high + } + return false +} + +// blocksDNS tells whether l keeps the system's DNS, TCP or UDP port 53 or +// Windows' DNS Client service, inside the TUN, to at least some addresses. +func (l leak) blocksDNS() bool { + return l.name == "windnsclient" || l.covers("udp", 53) || l.covers("tcp", 53) } // parseLeak parses a lower case value of autoSystemWfpBlockLeak. Ports go from -// 1 to 65535, low not above high, and "/1-65535" stands for all of them, as -// does no port at all. +// 1 to 65535, low not above high, and "tcp/1-65535" stands for all of them, as +// does "tcp". func parseLeak(value string) (leak, bool) { - l := leak{versions: bothIPVersions} - if version, rest, ok := strings.Cut(value, ":"); ok { - switch version { - case "ipv4": - l.versions = ipv4Version - case "ipv6": - l.versions = ipv6Version - default: + var l leak + cidr, rest, hasCIDR := strings.Cut(value, ":") + ipv6 := strings.HasPrefix(value, "[") + if ipv6 { + cidr, rest, hasCIDR = strings.Cut(value[1:], "]:") + } + if hasCIDR { + // IPv4-mapped IPv6 ones would not match: Windows checks such + // connections as IPv4. + prefix, err := netip.ParsePrefix(cidr) + if err != nil || prefix.Addr().Is6() != ipv6 || prefix.Addr().Is4In6() { return leak{}, false } - value = rest + l.prefix, value = prefix.Masked(), rest } name, ports, hasPorts := strings.Cut(value, "/") - l.name = name switch name { case "tcp", "udp": + l.name = name + if !hasPorts { + return l, true + } case "dns", "windnsclient", "misconfigtun", "all", "allexceptlan", "icmp": + l.name = name return l, !hasPorts default: - return leak{}, false - } - if !hasPorts { - return l, true + if hasPorts { + return leak{}, false + } + ports = name // for TCP and UDP } lowText, highText, isRange := strings.Cut(ports, "-") low, err := strconv.ParseUint(lowText, 10, 16) @@ -96,28 +111,22 @@ func parseLeak(value string) (leak, bool) { return leak{}, false } } - if low != 1 || high != 65535 { + if l.name == "" || low != 1 || high != 65535 { l.low, l.high = uint16(low), uint16(high) } return l, true } // ExpandWfpBlockLeak checks the values of autoSystemWfpBlockLeak and returns -// them in lower case and in order, with "dns" replaced by what it stands for, -// "allexceptlan" followed by that, and ports written the same way, "tcp/1-65535" -// as "tcp" say. Values that only differ in the IP versions become one for -// both: "ipv4:udp/53" and "ipv6:udp/53" turn into "udp/53", as does "udp/53" -// with "ipv4:udp/53". +// them in lower case, in order and each once, with "dns" replaced by what it +// stands for, "allexceptlan" followed by that, CIDRs masked, and ports written +// the same way, "tcp/1-65535" as "tcp" say. func ExpandWfpBlockLeak(values []string) ([]string, error) { - var leaks []leak + var expanded []string add := func(l leak) { - for i, added := range leaks { - if added.versions = l.versions; added == l { - leaks[i].versions |= l.versions - return - } + if value := l.String(); !slices.Contains(expanded, value) { + expanded = append(expanded, value) } - leaks = append(leaks, l) } for _, value := range values { l, ok := parseLeak(strings.ToLower(value)) @@ -132,24 +141,20 @@ func ExpandWfpBlockLeak(values []string) ([]string, error) { if l.name == "dns" || l.name == "allexceptlan" { for _, dnsLeak := range dnsLeaks { dns, _ := parseLeak(dnsLeak) - dns.versions = l.versions + dns.prefix = l.prefix add(dns) } } } - var expanded []string - for _, l := range leaks { - expanded = append(expanded, l.String()) - } return expanded, nil } // BlocksSystemDNS tells whether expanded autoSystemWfpBlockLeak values keep the // system's DNS (TCP or UDP port 53, or Windows' DNS Client service) inside the -// TUN, over at least one IP version, which then needs DNS servers of its own. +// TUN, to at least some addresses, which then needs DNS servers of its own. func BlocksSystemDNS(leaks []string) bool { return slices.ContainsFunc(leaks, func(value string) bool { l, _ := parseLeak(value) - return l.name == "windnsclient" || l.name == "all" || l.covers("udp", 53) || l.covers("tcp", 53) + return l.blocksDNS() }) } diff --git a/proxy/tun/wfp_leak_test.go b/proxy/tun/wfp_leak_test.go index c230d719a..a9be1f7d9 100644 --- a/proxy/tun/wfp_leak_test.go +++ b/proxy/tun/wfp_leak_test.go @@ -12,9 +12,9 @@ func TestExpandWfpBlockLeak(t *testing.T) { systemDNS bool }{ {nil, nil, false}, - {[]string{"dns"}, []string{"udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853"}, true}, - {[]string{"DNS", "MisconfigTUN"}, []string{"udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853", "misconfigtun"}, true}, - {[]string{"tcp/853", "dns", "TCP/0853"}, []string{"tcp/853", "udp/53", "tcp/53", "windnsclient", "udp/853"}, true}, + {[]string{"dns"}, []string{"53", "853", "windnsclient"}, true}, + {[]string{"DNS", "MisconfigTUN"}, []string{"53", "853", "windnsclient", "misconfigtun"}, true}, + {[]string{"tcp/853", "dns", "TCP/0853"}, []string{"tcp/853", "53", "853", "windnsclient"}, true}, {[]string{"tcp/853", "udp/853", "misconfigtun"}, []string{"tcp/853", "udp/853", "misconfigtun"}, false}, {[]string{"udp/65535", "tcp/1"}, []string{"udp/65535", "tcp/1"}, false}, {[]string{"tcp/53"}, []string{"tcp/53"}, true}, @@ -25,25 +25,26 @@ func TestExpandWfpBlockLeak(t *testing.T) { {[]string{"udp/10-20", "udp/53-53", "tcp/50-60"}, []string{"udp/10-20", "udp/53", "tcp/50-60"}, true}, {[]string{"tcp/54-60", "udp/1-52"}, []string{"tcp/54-60", "udp/1-52"}, false}, {[]string{"ICMP", "icmp"}, []string{"icmp"}, false}, - {[]string{"AllExceptLAN"}, []string{"allexceptlan", "udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853"}, true}, - {[]string{"dns", "allexceptlan", "all"}, []string{"udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853", "allexceptlan", "all"}, true}, - // For one IP version only. - {[]string{"IPv4:TCP/853", "ipv6:udp/10-20", "IPV4:icmp"}, []string{"ipv4:tcp/853", "ipv6:udp/10-20", "ipv4:icmp"}, false}, - {[]string{"ipv4:udp/53"}, []string{"ipv4:udp/53"}, true}, - {[]string{"ipv6:tcp/53-53"}, []string{"ipv6:tcp/53"}, true}, - {[]string{"ipv4:dns"}, []string{"ipv4:udp/53", "ipv4:tcp/53", "ipv4:windnsclient", "ipv4:tcp/853", "ipv4:udp/853"}, true}, - {[]string{"ipv6:all"}, []string{"ipv6:all"}, true}, - {[]string{"ipv4:windnsclient"}, []string{"ipv4:windnsclient"}, true}, - {[]string{"ipv6:misconfigtun"}, []string{"ipv6:misconfigtun"}, false}, - {[]string{"ipv4:allexceptlan", "ipv6:all"}, []string{"ipv4:allexceptlan", "ipv4:udp/53", "ipv4:tcp/53", "ipv4:windnsclient", "ipv4:tcp/853", "ipv4:udp/853", "ipv6:all"}, true}, - // Values that only differ in the IP versions become one. - {[]string{"ipv4:udp/53", "tcp", "ipv6:udp/53"}, []string{"udp/53", "tcp"}, true}, - {[]string{"ipv6:tcp/1-65535", "ipv4:tcp"}, []string{"tcp"}, true}, - {[]string{"ipv4:icmp", "icmp"}, []string{"icmp"}, false}, - {[]string{"ipv4:tcp/853", "ipv6:tcp/853-853", "ipv6:tcp/854"}, []string{"tcp/853", "ipv6:tcp/854"}, false}, - {[]string{"dns", "ipv4:dns"}, []string{"udp/53", "tcp/53", "windnsclient", "tcp/853", "udp/853"}, true}, - {[]string{"ipv4:dns", "ipv6:udp/53"}, []string{"udp/53", "ipv4:tcp/53", "ipv4:windnsclient", "ipv4:tcp/853", "ipv4:udp/853"}, true}, - {[]string{"ipv4:misconfigtun", "ipv6:misconfigtun", "ipv6:all", "ipv4:all"}, []string{"misconfigtun", "all"}, true}, + {[]string{"AllExceptLAN"}, []string{"allexceptlan", "53", "853", "windnsclient"}, true}, + {[]string{"dns", "allexceptlan", "all"}, []string{"53", "853", "windnsclient", "allexceptlan", "all"}, true}, + // Ports alone, for TCP and UDP. + {[]string{"53"}, []string{"53"}, true}, + {[]string{"10-20", "0053", "853-853", "53"}, []string{"10-20", "53", "853"}, true}, + {[]string{"54-60", "1-52", "65535"}, []string{"54-60", "1-52", "65535"}, false}, + {[]string{"1-65535"}, []string{"1-65535"}, true}, + {[]string{"853", "tcp/853"}, []string{"853", "tcp/853"}, false}, + // Limited to the addresses of a CIDR. + {[]string{"1.1.1.0/24:53"}, []string{"1.1.1.0/24:53"}, true}, + {[]string{"1.1.1.1/24:TCP/853", "[2001:DB8::1/32]:udp/10-20", "[::/0]:ICMP"}, []string{"1.1.1.0/24:tcp/853", "[2001:db8::/32]:udp/10-20", "[::/0]:icmp"}, false}, + {[]string{"0.0.0.0/0:dns"}, []string{"0.0.0.0/0:53", "0.0.0.0/0:853", "0.0.0.0/0:windnsclient"}, true}, + {[]string{"[::/0]:all"}, []string{"[::/0]:all"}, true}, + {[]string{"10.0.0.1/32:windnsclient"}, []string{"10.0.0.1/32:windnsclient"}, true}, + {[]string{"[2000::/3]:misconfigtun"}, []string{"[2000::/3]:misconfigtun"}, false}, + {[]string{"1.1.1.0/24:tcp/1-65535", "1.1.1.0/24:udp/53-53"}, []string{"1.1.1.0/24:tcp", "1.1.1.0/24:udp/53"}, true}, + {[]string{"1.1.1.0/24:20-30", "[::/0]:10-10"}, []string{"1.1.1.0/24:20-30", "[::/0]:10"}, false}, + {[]string{"0.0.0.0/0:allexceptlan", "[::/0]:all"}, []string{"0.0.0.0/0:allexceptlan", "0.0.0.0/0:53", "0.0.0.0/0:853", "0.0.0.0/0:windnsclient", "[::/0]:all"}, true}, + {[]string{"1.1.1.1/32:53", "1.1.1.1/32:0053", "1.1.1.1/24:53", "1.1.1.0/24:53", "53"}, []string{"1.1.1.1/32:53", "1.1.1.0/24:53", "53"}, true}, + {[]string{"1.2.3.4/0:icmp", "[::1/0]:icmp", "icmp"}, []string{"0.0.0.0/0:icmp", "[::/0]:icmp", "icmp"}, false}, } { leaks, err := ExpandWfpBlockLeak(c.values) if err != nil { @@ -61,9 +62,18 @@ func TestExpandWfpBlockLeak(t *testing.T) { "", "ip", "tcp/", "udp/65536", "tcp/-1", "tcp/+53", "tcp/53/1", "udp/abc", "icmp/0", "icmp/8", "tcp:53", " dns", "tcp/0-10", "tcp/20-10", "tcp/5-", "tcp/-5", "tcp/1-2-3", "tcp/1-65536", "udp/ 53", "all/0", "dns/53", "windnsclient/53", "tcp/0", "udp/0", "tcp/00", "udp/0-0", "udp/65535-1", - "ipv4", "ipv6", "ipv4:", ":udp", "ipv5:udp", "ip:all", "v4:udp", "ipv4/udp", "ipv4 :udp", "ipv4: udp", "ipv4:ipv6:udp", - "ipv4:ipv4", "ipv4:tcp/0", "ipv6:icmp/8", "ipv4:dns/53", "ipv6:udp/20-10", "ipv4+ipv6:udp", - "tcpv4", "udpv6", "icmpv4", "tcpv4/853", "tcp4", "udp6", "dnsv4", "allv6", + // Ports alone. + "0", "00", "65536", "0-10", "20-10", "5-", "-5", "-", "1-2-3", "1-65536", "+53", " 53", "53 ", "53/1", "53/tcp", "0x35", + // IP versions are CIDRs now. + "ipv4", "ipv6", "ipv4:udp", "ipv6:all", "ipv4:dns", "tcpv4", "udpv6", "icmpv4", "tcp4", "dnsv4", + // CIDRs, IPv6 ones in brackets, but neither single addresses nor + // CIDRs on their own. + "1.1.1.0/24", "[2001:db8::/32]", "1.1.1.0/24:", "[::/0]:", ":udp", "[]:udp", "[:udp", "[::/0]udp", + "1.1.1.1:53", "[::1]:53", "[2001:db8::1]:tcp/53", "2001:db8::/32:53", "::/0:all", "[1.1.1.0/24]:53", + "[2001:db8::]/32:53", "1.1.1.0/33:53", "[::/129]:53", "1.1.1.0/24:1.1.1.0/24:53", "[::/0]:[::/0]:53", + "1.1.1.0/24 :53", "1.1.1.0/24: 53", " 1.1.1.0/24:53", "1.1.1.0/24::53", "[fe80::%1/64]:53", "geoip:cn:53", + "1.1.1.0/24:udp/0", "[::/0]:icmp/8", "1.1.1.0/24:dns/53", "[::/0]:20-10", "1.1.1.0/24:ipv4:53", "1.1.1.0/24:geoip", + "[::ffff:1.1.1.0/120]:53", "[::ffff:0:0/96]:all", } { if _, err := ExpandWfpBlockLeak([]string{value}); err == nil { t.Errorf("%q was accepted", value) @@ -86,10 +96,19 @@ func TestLeakCovers(t *testing.T) { {"tcp/10-20", "tcp", 20, true}, {"tcp/10-20", "tcp", 21, false}, {"tcp/10-20", "tcp", 9, false}, - {"ipv4:udp/53", "udp", 53, true}, - {"ipv6:tcp", "tcp", 443, true}, - {"ipv4:icmp", "udp", 53, false}, - {"all", "udp", 53, false}, + {"53", "udp", 53, true}, + {"53", "tcp", 53, true}, + {"10-20", "tcp", 15, true}, + {"10-20", "udp", 21, false}, + {"1.1.1.0/24:udp/53", "udp", 53, true}, + {"[::/0]:tcp", "tcp", 443, true}, + {"[2001:db8::/32]:53", "tcp", 53, true}, + {"0.0.0.0/0:icmp", "udp", 53, false}, + {"all", "udp", 53, true}, + {"1.1.1.1/32:all", "tcp", 1, true}, + {"allexceptlan", "udp", 53, false}, + {"misconfigtun", "udp", 53, false}, + {"windnsclient", "udp", 53, false}, } { l, ok := parseLeak(c.value) if !ok {