Files
Xray-core/proxy/tun/tun_freebsd_dns_test.go
T

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")
}
}