mirror of
https://github.com/XTLS/Xray-core.git
synced 2026-10-27 21:46:58 +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:
@@ -0,0 +1,390 @@
|
||||
//go:build freebsd
|
||||
|
||||
package tun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type probeCall struct {
|
||||
inboundTag string
|
||||
source string
|
||||
address string
|
||||
}
|
||||
|
||||
// stubDNSRouting replaces the routing probe for the duration of a test and
|
||||
// records how it was called, so tests can assert the probe is representative.
|
||||
func stubDNSRouting(t *testing.T, err error) *[]probeCall {
|
||||
t.Helper()
|
||||
original := verifyDNSRouting
|
||||
calls := []probeCall{}
|
||||
verifyDNSRouting = func(_ context.Context, inboundTag, source, address string) error {
|
||||
calls = append(calls, probeCall{inboundTag, source, address})
|
||||
return err
|
||||
}
|
||||
t.Cleanup(func() { verifyDNSRouting = original })
|
||||
return &calls
|
||||
}
|
||||
|
||||
// recorder installs a resolvconf stub for the duration of a test and returns
|
||||
// the captured invocations, each as the arguments followed by the stdin in
|
||||
// angle brackets when there was one. An empty failOn succeeds every call;
|
||||
// otherwise the named subcommand fails.
|
||||
func recorder(t *testing.T, failOn string) *[]string {
|
||||
t.Helper()
|
||||
original := resolvconfRunner
|
||||
calls := []string{}
|
||||
resolvconfRunner = func(stdin string, args ...string) ([]byte, error) {
|
||||
call := strings.Join(args, " ")
|
||||
if stdin != "" {
|
||||
call += " <" + stdin + ">"
|
||||
}
|
||||
calls = append(calls, call)
|
||||
if failOn != "" && len(args) > 0 && args[0] == failOn {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
t.Cleanup(func() { resolvconfRunner = original })
|
||||
return &calls
|
||||
}
|
||||
|
||||
// files replaces the resolver files read by the applied check for the
|
||||
// duration of a test. A path not in the map does not exist.
|
||||
func files(t *testing.T, contents map[string]string) {
|
||||
t.Helper()
|
||||
original := readResolverFile
|
||||
readResolverFile = func(path string) ([]byte, error) {
|
||||
if data, ok := contents[path]; ok {
|
||||
return []byte(data), nil
|
||||
}
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
t.Cleanup(func() { readResolverFile = original })
|
||||
}
|
||||
|
||||
// takenOver is what the libc subscriber writes once the TUN's nameserver is
|
||||
// the exclusive one.
|
||||
const takenOver = "# Generated by resolvconf\nnameserver 192.168.100.1\n"
|
||||
|
||||
// untouched is the DHCP-supplied resolv.conf that resolvconf=NO leaves in
|
||||
// place.
|
||||
const untouched = "# Generated by resolvconf\nnameserver 10.0.0.1\n"
|
||||
|
||||
func optedInTun() *FreeBSDTun {
|
||||
return &FreeBSDTun{
|
||||
options: &Config{
|
||||
Name: "tun7",
|
||||
Gateway: []string{"192.168.100.1/30"},
|
||||
AutoSystemDnsToGateway: true,
|
||||
},
|
||||
name: "tun7",
|
||||
gateway: netip.MustParsePrefix("192.168.100.1/30"),
|
||||
local: netip.MustParseAddr("192.168.100.2"),
|
||||
}
|
||||
}
|
||||
|
||||
func joined(calls []string) string {
|
||||
return strings.Join(calls, " | ")
|
||||
}
|
||||
|
||||
func TestConfigureSystemDNSDisabledByDefault(t *testing.T) {
|
||||
probes := stubDNSRouting(t, nil)
|
||||
calls := recorder(t, "")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
t1.options.AutoSystemDnsToGateway = false
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(*calls) != 0 || len(*probes) != 0 {
|
||||
t.Errorf("nothing may run when the option is off, got %q and %d probes", joined(*calls), len(*probes))
|
||||
}
|
||||
if t1.systemDNSSet {
|
||||
t.Error("systemDNSSet must stay false when the option is off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureSystemDNSLeavesOSDNSWhenNoRoute(t *testing.T) {
|
||||
stubDNSRouting(t, errors.New("no route"))
|
||||
calls := recorder(t, "")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil {
|
||||
t.Fatal("expected an error when the probe fails")
|
||||
}
|
||||
if len(*calls) != 0 {
|
||||
t.Errorf("resolvconf must not run when the probe fails, got %q", joined(*calls))
|
||||
}
|
||||
if t1.systemDNSSet {
|
||||
t.Error("systemDNSSet must stay false when the probe fails")
|
||||
}
|
||||
}
|
||||
|
||||
// On FreeBSD the addresses are the other way round from Linux: the local
|
||||
// address is the one after the gateway, and the gateway itself has no owner,
|
||||
// so a packet to it is routed into the TUN. The probe has to carry that pair,
|
||||
// and the resolver has to be pointed at the gateway.
|
||||
func TestConfigureSystemDNSProbeCarriesFreeBSDPair(t *testing.T) {
|
||||
probes := stubDNSRouting(t, nil)
|
||||
recorder(t, "")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(*probes) != 1 {
|
||||
t.Fatalf("expected one probe, got %d", len(*probes))
|
||||
}
|
||||
want := probeCall{"tun", "192.168.100.2", "192.168.100.1"}
|
||||
if (*probes)[0] != want {
|
||||
t.Errorf("probe = %+v, want %+v", (*probes)[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureSystemDNSAppliesResolvconf(t *testing.T) {
|
||||
stubDNSRouting(t, nil)
|
||||
calls := recorder(t, "")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !t1.systemDNSSet {
|
||||
t.Fatal("systemDNSSet should be true after a successful takeover")
|
||||
}
|
||||
|
||||
want := "-a tun7 -x <nameserver 192.168.100.1\n>"
|
||||
if got := joined(*calls); got != want {
|
||||
t.Errorf("resolvconf calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvconf exits 0 with resolvconf=NO in resolvconf.conf, and still records
|
||||
// the entry for the interface, but runs no subscriber, so success has to be
|
||||
// confirmed in what the subscribers wrote.
|
||||
func TestConfigureSystemDNSRefusesWhenResolvconfDidNotApply(t *testing.T) {
|
||||
stubDNSRouting(t, nil)
|
||||
calls := recorder(t, "")
|
||||
files(t, map[string]string{"/etc/resolv.conf": untouched})
|
||||
|
||||
t1 := optedInTun()
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil {
|
||||
t.Fatal("expected an error when the nameserver did not reach a resolver")
|
||||
}
|
||||
if t1.systemDNSSet {
|
||||
t.Error("systemDNSSet must stay false when the takeover did not take")
|
||||
}
|
||||
if !strings.HasSuffix(joined(*calls), "-d tun7") {
|
||||
t.Errorf("expected a removal after the failed check, got %q", joined(*calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureSystemDNSIdempotent(t *testing.T) {
|
||||
stubDNSRouting(t, nil)
|
||||
calls := recorder(t, "")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
before := len(*calls)
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(*calls) != before {
|
||||
t.Errorf("second call must be a no-op, calls went %d -> %d", before, len(*calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureSystemDNSAddFailureIsNotDirty(t *testing.T) {
|
||||
stubDNSRouting(t, nil)
|
||||
calls := recorder(t, "-a")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil {
|
||||
t.Fatal("expected an error when resolvconf -a fails")
|
||||
}
|
||||
if t1.systemDNSSet || t1.systemDNSDirty {
|
||||
t.Error("a failed add leaves nothing to clean up")
|
||||
}
|
||||
if len(*calls) != 1 {
|
||||
t.Errorf("nothing else may run after a failed add, got %q", joined(*calls))
|
||||
}
|
||||
}
|
||||
|
||||
// If the removal after a failed check fails too, the entry may still be
|
||||
// installed, so the state has to be remembered rather than silently dropped.
|
||||
func TestConfigureSystemDNSRollbackFailureKeepsDirty(t *testing.T) {
|
||||
stubDNSRouting(t, nil)
|
||||
recorder(t, "-d")
|
||||
files(t, map[string]string{"/etc/resolv.conf": untouched})
|
||||
|
||||
t1 := optedInTun()
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil {
|
||||
t.Fatal("expected an error when the check fails")
|
||||
}
|
||||
if !t1.systemDNSDirty {
|
||||
t.Error("a failed removal must leave the resolver marked dirty")
|
||||
}
|
||||
if t1.systemDNSSet {
|
||||
t.Error("systemDNSSet must stay false when the takeover did not complete")
|
||||
}
|
||||
}
|
||||
|
||||
// A dirty resolver is retried before anything new is applied.
|
||||
func TestConfigureSystemDNSRetriesDirtyBeforeApplying(t *testing.T) {
|
||||
stubDNSRouting(t, nil)
|
||||
calls := recorder(t, "")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
t1.systemDNSDirty = true
|
||||
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := joined(*calls)
|
||||
if !strings.HasPrefix(got, "-d tun7 | -a tun7") {
|
||||
t.Errorf("expected the stale removal first, got %q", got)
|
||||
}
|
||||
if t1.systemDNSDirty {
|
||||
t.Error("a successful retry should clear the dirty flag")
|
||||
}
|
||||
if !t1.systemDNSSet {
|
||||
t.Error("the takeover should proceed once the retry succeeds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsetSystemDNSRemoves(t *testing.T) {
|
||||
stubDNSRouting(t, nil)
|
||||
calls := recorder(t, "")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
*calls = nil
|
||||
|
||||
t1.unsetSystemDNS()
|
||||
if t1.systemDNSSet {
|
||||
t.Error("systemDNSSet should be false after unset")
|
||||
}
|
||||
if got := joined(*calls); got != "-d tun7" {
|
||||
t.Errorf("unset calls = %q, want %q", got, "-d tun7")
|
||||
}
|
||||
|
||||
t1.unsetSystemDNS()
|
||||
if len(*calls) != 1 {
|
||||
t.Errorf("unsetSystemDNS must be idempotent, got %q", joined(*calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsetSystemDNSKeepsDirtyWhenRemoveFails(t *testing.T) {
|
||||
stubDNSRouting(t, nil)
|
||||
calls := recorder(t, "-d")
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
|
||||
t1 := optedInTun()
|
||||
t1.systemDNSSet = true
|
||||
|
||||
t1.unsetSystemDNS()
|
||||
if !t1.systemDNSDirty {
|
||||
t.Error("a failed removal during unset must be remembered")
|
||||
}
|
||||
if got := joined(*calls); got != "-d tun7" {
|
||||
t.Errorf("expected a removal attempt, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvconfAppliedLibc(t *testing.T) {
|
||||
files(t, map[string]string{"/etc/resolv.conf": takenOver})
|
||||
if ok, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err != nil || !ok {
|
||||
t.Errorf("resolv.conf listing the nameserver must count as applied, got %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvconfAppliedNotWhenUntouched(t *testing.T) {
|
||||
files(t, map[string]string{"/etc/resolv.conf": untouched})
|
||||
if ok, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err != nil || ok {
|
||||
t.Errorf("an unchanged resolv.conf and no resolvconf.conf must not count as applied, got %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// With inclusive_interfaces in resolvconf.conf the exclusive marking is
|
||||
// ignored and the DHCP nameservers stay listed, so the system falls back to
|
||||
// them; that is the leak the option exists to close, and it must not pass.
|
||||
func TestResolvconfAppliedNotWhenOthersRemain(t *testing.T) {
|
||||
files(t, map[string]string{"/etc/resolv.conf": "nameserver 192.168.100.1\nnameserver 10.0.0.1\n"})
|
||||
if ok, _ := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); ok {
|
||||
t.Error("another nameserver alongside the TUN's must not count as applied")
|
||||
}
|
||||
}
|
||||
|
||||
// A loopback nameserver alongside is a local resolver that resolvconf feeds
|
||||
// (local_unbound with the libc subscriber left on), not a leak.
|
||||
func TestResolvconfAppliedWithLoopbackAlongside(t *testing.T) {
|
||||
files(t, map[string]string{"/etc/resolv.conf": "nameserver 127.0.0.1\nnameserver 192.168.100.1\n"})
|
||||
if ok, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err != nil || !ok {
|
||||
t.Errorf("a loopback nameserver alongside must still count as applied, got %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvconfAppliedIgnoresPrefixMatches(t *testing.T) {
|
||||
files(t, map[string]string{"/etc/resolv.conf": "nameserver 192.168.100.10\n"})
|
||||
if ok, _ := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); ok {
|
||||
t.Error("192.168.100.10 must not match 192.168.100.1")
|
||||
}
|
||||
}
|
||||
|
||||
// local-unbound-setup turns the libc subscriber off and has resolvconf write
|
||||
// unbound's forwarders instead; resolv.conf then stays pointed at loopback.
|
||||
func TestResolvconfAppliedUnbound(t *testing.T) {
|
||||
files(t, map[string]string{
|
||||
"/etc/resolv.conf": "# Generated by resolvconf\nnameserver 127.0.0.1\noptions edns0\n",
|
||||
"/etc/resolvconf.conf": "libc=\"NO\"\nunbound_conf=\"/var/unbound/forward.conf\"\n",
|
||||
"/var/unbound/forward.conf": "forward-zone:\n\tname: .\n\tforward-addr: 192.168.100.1\n",
|
||||
})
|
||||
if ok, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err != nil || !ok {
|
||||
t.Errorf("the nameserver in unbound's forwarders must count as applied, got %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvconfAppliedNotWhenUnboundUntouched(t *testing.T) {
|
||||
files(t, map[string]string{
|
||||
"/etc/resolv.conf": "nameserver 127.0.0.1\n",
|
||||
"/etc/resolvconf.conf": "libc=\"NO\"\nunbound_conf=\"/var/unbound/forward.conf\"\n",
|
||||
"/var/unbound/forward.conf": "forward-zone:\n\tname: .\n\tforward-addr: 10.0.0.1\n",
|
||||
})
|
||||
if ok, _ := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); ok {
|
||||
t.Error("unbound still forwarding to the old server must not count as applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvconfAppliedReportsUnreadableResolvConf(t *testing.T) {
|
||||
files(t, map[string]string{})
|
||||
if _, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err == nil {
|
||||
t.Error("a missing resolv.conf is an error, not a verdict")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user