brookwko
2026-10-12 02:48:59 +00:00
committed by RPRX
parent 70ef97d564
commit 1dd2a0bd3c
7 changed files with 747 additions and 115 deletions
+222 -13
View File
@@ -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()