mirror of
https://github.com/XTLS/Xray-core.git
synced 2026-10-28 05:57:02 +00:00
TUN inbound: Support autoSystemDnsToGateway on FreeBSD as well (#7123)
https://github.com/XTLS/Xray-core/issues/6454#issuecomment-5859521287 https://github.com/XTLS/Xray-core/pull/6691#issuecomment-5445818753 https://github.com/XTLS/Xray-core/pull/6773 & https://github.com/XTLS/Xray-core/pull/6853
This commit is contained in:
+222
-13
@@ -5,10 +5,13 @@ package tun
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
@@ -38,8 +41,17 @@ func procyield(cycles uint32)
|
||||
type FreeBSDTun struct {
|
||||
device tun.Device
|
||||
options *Config
|
||||
name string
|
||||
tunIndex int
|
||||
autoInterface bool
|
||||
// local is the address the system itself has on the TUN; gateway is the
|
||||
// address before it, which nothing owns, so a packet to it is routed into
|
||||
// the TUN (see selectFreeBSDGateway).
|
||||
gateway netip.Prefix
|
||||
local netip.Addr
|
||||
|
||||
systemDNSSet bool
|
||||
systemDNSDirty bool
|
||||
|
||||
systemRoutes []netip.Prefix
|
||||
escapeMu sync.Mutex
|
||||
@@ -102,15 +114,19 @@ func NewTun(options *Config) (Tun, error) {
|
||||
return &FreeBSDTun{
|
||||
device: tunDev,
|
||||
options: options,
|
||||
name: name,
|
||||
tunIndex: iface.Index,
|
||||
autoInterface: options.AutoOutboundsInterface != "",
|
||||
gateway: gateway,
|
||||
local: local,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// selectFreeBSDGateway picks the first IPv4 prefix from the configured gateway
|
||||
// list and the local address derived from it (the darwin semantics: the
|
||||
// gateway is the remote side of the point-to-point pair, the local address is
|
||||
// the next one after it), falling back to the same link-local default.
|
||||
// interface gets the address right after the gateway, and the gateway, which
|
||||
// nothing owns, is routed into the TUN), falling back to the same link-local
|
||||
// default.
|
||||
func selectFreeBSDGateway(configured []string) (netip.Prefix, netip.Addr, error) {
|
||||
gateway := netip.MustParsePrefix(defaultFreeBSDGateway)
|
||||
if len(configured) > 0 {
|
||||
@@ -199,6 +215,7 @@ func (t *FreeBSDTun) Close() error {
|
||||
_ = t.routeMonitor.Close()
|
||||
}
|
||||
})
|
||||
t.unsetSystemDNS()
|
||||
t.unsetEscapeFib()
|
||||
routeErr := t.unsetSystemRoutes()
|
||||
name, nameErr := t.Name()
|
||||
@@ -212,6 +229,195 @@ func (t *FreeBSDTun) Close() error {
|
||||
return xerrors.Combine(routeErr, closeErr)
|
||||
}
|
||||
|
||||
// resolvconfRunner runs resolvconf(8) with the given stdin. Overridable for
|
||||
// tests.
|
||||
var resolvconfRunner = func(stdin string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("resolvconf", args...)
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// readResolverFile reads one of the files the applied check looks at.
|
||||
// Overridable for tests.
|
||||
var readResolverFile = os.ReadFile
|
||||
|
||||
// resolvconfApplied reports whether the nameserver resolvconf was handed
|
||||
// reached a resolver. resolvconf exits 0 with resolvconf=NO in
|
||||
// resolvconf.conf, and still records the entry for the interface, but runs no
|
||||
// subscriber, so the exit status and `resolvconf -l` prove nothing; what the
|
||||
// subscribers wrote does. The libc subscriber writes /etc/resolv.conf, where
|
||||
// the nameserver also has to be the exclusive one. With
|
||||
// local_unbound, local-unbound-setup turns that subscriber off and names
|
||||
// unbound's forwarders file as unbound_conf instead, with resolv.conf left
|
||||
// pointed at loopback, so that file is checked next.
|
||||
func resolvconfApplied(addr netip.Addr) (bool, error) {
|
||||
resolvConf, err := readResolverFile("/etc/resolv.conf")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exclusiveNameserver(resolvConf, addr) {
|
||||
return true, nil
|
||||
}
|
||||
resolvconfConf, err := readResolverFile("/etc/resolvconf.conf")
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if path := shellAssignment(resolvconfConf, "unbound_conf"); path != "" {
|
||||
forwarders, err := readResolverFile(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hasDirective(forwarders, "forward-addr:", addr) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// exclusiveNameserver reports whether resolv.conf lists addr as a nameserver
|
||||
// and no other one except on loopback. With inclusive_interfaces in
|
||||
// resolvconf.conf the exclusive marking of -x is ignored and the nameservers
|
||||
// DHCP supplied stay listed, and the system falls back to them: that is the
|
||||
// leak the takeover exists to close, so it does not count as applied. A
|
||||
// loopback nameserver alongside is a local resolver that resolvconf feeds.
|
||||
func exclusiveNameserver(resolvConf []byte, addr netip.Addr) bool {
|
||||
found := false
|
||||
for _, line := range strings.Split(string(resolvConf), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) != 2 || fields[0] != "nameserver" {
|
||||
continue
|
||||
}
|
||||
if fields[1] == addr.String() {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
if other, err := netip.ParseAddr(fields[1]); err != nil || !other.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// hasDirective reports whether a line of content is the keyword followed by
|
||||
// the address, as in `forward-addr: 10.0.0.1`.
|
||||
func hasDirective(content []byte, keyword string, addr netip.Addr) bool {
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 2 && fields[0] == keyword && fields[1] == addr.String() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shellAssignment returns the value of `name=value` in a sh-style config such
|
||||
// as resolvconf.conf, without surrounding quotes, or empty when it is not set.
|
||||
func shellAssignment(content []byte, name string) string {
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
value, ok := strings.CutPrefix(line, name+"=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
return strings.Trim(value, `"'`)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ConfigureSystemDNS hands the TUN's gateway address to resolvconf(8) as the
|
||||
// exclusive nameserver, 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 gateway would actually be handled, host-wide
|
||||
// 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 *FreeBSDTun) ConfigureSystemDNS(ctx context.Context, inboundTag string) error {
|
||||
if !t.options.AutoSystemDnsToGateway {
|
||||
return nil
|
||||
}
|
||||
if t.systemDNSSet {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A previous removal may have failed. Retry before applying anything, so a
|
||||
// stale entry does not silently outlive the attempt to clean it up.
|
||||
if t.systemDNSDirty {
|
||||
if err := t.revertSystemDNS(); err != nil {
|
||||
return xerrors.New("previous system DNS removal still failing").Base(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver is pointed at the gateway: on FreeBSD the system has no
|
||||
// address there, so a query to it is routed into the TUN and answered
|
||||
// inside Xray. A query from this host carries the local address.
|
||||
address := t.gateway.Addr()
|
||||
if err := verifyDNSRouting(ctx, inboundTag, t.local.String(), address.String()); err != nil {
|
||||
return xerrors.New("no DNS path at ", address.String(), ":53").Base(err)
|
||||
}
|
||||
|
||||
// -x makes this interface's nameserver the only one in use, the
|
||||
// equivalent of resolvectl's `domain ~.` plus `default-route true`:
|
||||
// without it openresolv keeps the nameservers dhclient supplied listed
|
||||
// alongside the gateway, and the system falls back to them.
|
||||
if _, err := resolvconfRunner("nameserver "+address.String()+"\n", "-a", t.name, "-x"); err != nil {
|
||||
return xerrors.New("resolvconf -a failed").Base(err)
|
||||
}
|
||||
applied, err := resolvconfApplied(address)
|
||||
if err != nil {
|
||||
return t.rollbackSystemDNS(xerrors.New("cannot confirm the system DNS").Base(err))
|
||||
}
|
||||
if !applied {
|
||||
return t.rollbackSystemDNS(xerrors.New("resolvconf did not apply ", address.String(), " to any resolver (resolvconf=NO or inclusive_interfaces in resolvconf.conf?)"))
|
||||
}
|
||||
|
||||
t.systemDNSSet = true
|
||||
xerrors.LogInfo(ctx, "[tun] system DNS set to ", address.String(), " on ", t.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollbackSystemDNS undoes a takeover that did not take. A failed removal is
|
||||
// recorded so the next attempt retries it, and is reported rather than
|
||||
// swallowed.
|
||||
func (t *FreeBSDTun) rollbackSystemDNS(cause error) error {
|
||||
if _, err := resolvconfRunner("", "-d", t.name); err != nil {
|
||||
t.systemDNSDirty = true
|
||||
// Combine, because Base overwrites: reporting only the cause would hide
|
||||
// the removal failure, and reporting only the removal failure would
|
||||
// hide why the removal was attempted.
|
||||
return xerrors.New("resolvconf -d failed, the entry may remain").Base(xerrors.Combine(err, cause))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// revertSystemDNS issues the removal and keeps the dirty flag in step with the
|
||||
// outcome.
|
||||
func (t *FreeBSDTun) revertSystemDNS() error {
|
||||
_, err := resolvconfRunner("", "-d", t.name)
|
||||
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 removal failed.
|
||||
func (t *FreeBSDTun) unsetSystemDNS() {
|
||||
if !t.systemDNSSet && !t.systemDNSDirty {
|
||||
return
|
||||
}
|
||||
if err := t.revertSystemDNS(); err != nil {
|
||||
xerrors.LogInfoInner(context.Background(), err, "[tun] failed to remove the system DNS entry; run `resolvconf -d ", t.name, "` to clean up")
|
||||
}
|
||||
}
|
||||
|
||||
func destroyInterface(name string) {
|
||||
fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
|
||||
if err != nil {
|
||||
@@ -286,7 +492,8 @@ const (
|
||||
)
|
||||
|
||||
// ifAliasReq4 is struct in_aliasreq from netinet/in_var.h in the 64-byte
|
||||
// layout unix.SIOCAIFADDR encodes (name + addr/dstaddr/mask sockaddrs).
|
||||
// layout unix.SIOCAIFADDR encodes (name + addr/dstaddr/mask sockaddrs; the
|
||||
// dstaddr slot is the broadcast address on a broadcast interface).
|
||||
type ifAliasReq4 struct {
|
||||
Name [unix.IFNAMSIZ]byte
|
||||
Addr unix.RawSockaddrInet4
|
||||
@@ -321,7 +528,7 @@ type addrLifetime6 struct {
|
||||
// header macro does.
|
||||
const siocaifaddrIn6 = 0x80000000 | (uintptr(unsafe.Sizeof(ifAliasReq6{})) << 16) | ('i' << 8) | 27
|
||||
|
||||
// setIPAddress assigns the local/remote point-to-point IPv4 pair and a
|
||||
// setIPAddress assigns the local IPv4 address with the gateway's mask and a
|
||||
// link-local IPv6 address to the interface, required for the routing to work
|
||||
// (same scheme as the darwin implementation: local address is the one right
|
||||
// after the gateway address).
|
||||
@@ -334,17 +541,20 @@ func setIPAddress(name string, gateway netip.Prefix, local netip.Addr, ifIndex i
|
||||
|
||||
local4 := local.As4()
|
||||
|
||||
// The interface runs in broadcast mode (the wireguard tun library puts it
|
||||
// there), so the second address slot of the request is the broadcast
|
||||
// address, not a point-to-point peer. It stays empty and the kernel
|
||||
// derives the broadcast address from the mask; the gateway is then an
|
||||
// ordinary on-link address, reachable through the interface route, which
|
||||
// is what the system routes and the system DNS takeover need. Filling it
|
||||
// with the gateway made the gateway the broadcast address, and a plain
|
||||
// socket cannot send to that.
|
||||
ifReq4 := ifAliasReq4{
|
||||
Addr: unix.RawSockaddrInet4{
|
||||
Len: unix.SizeofSockaddrInet4,
|
||||
Family: unix.AF_INET,
|
||||
Addr: local4,
|
||||
},
|
||||
Dstaddr: unix.RawSockaddrInet4{
|
||||
Len: unix.SizeofSockaddrInet4,
|
||||
Family: unix.AF_INET,
|
||||
Addr: gateway.Addr().As4(),
|
||||
},
|
||||
Mask: unix.RawSockaddrInet4{
|
||||
Len: unix.SizeofSockaddrInet4,
|
||||
Family: unix.AF_INET,
|
||||
@@ -431,10 +641,9 @@ func (t *FreeBSDTun) setSystemRoutes() error {
|
||||
return err
|
||||
}
|
||||
// Route through the interface, not a gateway: the tun(4) device is a
|
||||
// broadcast interface here, so its point-to-point peer address doubles as
|
||||
// the subnet broadcast and the kernel refuses to route to it (EACCES).
|
||||
// Interface routes sidestep the gateway entirely (what wg-quick does on
|
||||
// FreeBSD).
|
||||
// broadcast interface here with no address resolution, so a next hop adds
|
||||
// nothing. Interface routes sidestep the gateway entirely (what wg-quick
|
||||
// does on FreeBSD).
|
||||
for _, destination := range routes {
|
||||
if err := execRoute(-1, unix.RTM_ADD, t.tunIndex, destination, netip.Addr{}); err != nil {
|
||||
_ = t.unsetSystemRoutes()
|
||||
|
||||
Reference in New Issue
Block a user