mirror of
https://github.com/XTLS/Xray-core.git
synced 2026-10-13 11:05:46 +00:00
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
391 lines
13 KiB
Go
391 lines
13 KiB
Go
//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")
|
|
}
|
|
}
|