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

437 lines
12 KiB
Go

//go:build linux && !android
package tun
import (
"context"
"errors"
"strings"
"testing"
"github.com/vishvananda/netlink"
)
// testLink returns a minimal netlink.Link whose Attrs().Name is name, so the
// DNS helpers can be exercised without a real TUN device.
func testLink(name string) netlink.Link {
return &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: name}}
}
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 resolvectl stub for the duration of a test and returns the
// captured invocations. An empty failOn succeeds every call; otherwise the named
// subcommand fails.
func recorder(t *testing.T, failOn string) *[][]string {
t.Helper()
original := resolvectlRunner
calls := [][]string{}
resolvectlRunner = func(name string, args ...string) ([]byte, error) {
calls = append(calls, append([]string{name}, args...))
if failOn != "" && len(args) > 0 && args[0] == failOn {
return nil, errors.New("boom")
}
return nil, nil
}
t.Cleanup(func() { resolvectlRunner = original })
return &calls
}
func optedInTun() *LinuxTun {
return &LinuxTun{
options: &Config{
Name: "xray_tun",
Gateway: []string{"192.168.100.1/30"},
AutoSystemDns: true,
},
tunLink: testLink("xray_tun"),
}
}
func joined(calls [][]string) string {
parts := make([]string, 0, len(calls))
for _, call := range calls {
parts = append(parts, strings.Join(call, " "))
}
return strings.Join(parts, " | ")
}
func TestConfigureSystemDNSDisabledByDefault(t *testing.T) {
probes := stubDNSRouting(t, nil)
calls := recorder(t, "")
t1 := optedInTun()
t1.options.AutoSystemDns = false
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(*probes) != 0 {
t.Errorf("routing probe must not run when disabled, got %d calls", len(*probes))
}
if len(*calls) != 0 {
t.Errorf("resolvectl must not run when disabled, got %v", *calls)
}
if t1.systemDNSSet {
t.Error("systemDNSSet should stay false when disabled")
}
}
func TestConfigureSystemDNSNoGateway(t *testing.T) {
probes := stubDNSRouting(t, nil)
calls := recorder(t, "")
t1 := optedInTun()
t1.options.Gateway = nil
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil {
t.Fatal("expected an error when no IPv4 gateway is configured")
}
if len(*probes) != 0 {
t.Errorf("routing probe must not run without a gateway, got %d calls", len(*probes))
}
if len(*calls) != 0 {
t.Errorf("resolvectl must not run without a gateway, got %v", *calls)
}
}
// This is the case the reviewer flagged: without a routed DNS path, pointing the
// system resolver at the derived address would break resolution outright.
func TestConfigureSystemDNSLeavesOSDNSWhenNoRoute(t *testing.T) {
probes := stubDNSRouting(t, errors.New("no route"))
calls := recorder(t, "")
t1 := optedInTun()
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil {
t.Fatal("expected an error when the DNS path is unverified")
}
if len(*probes) != 1 {
t.Errorf("routing probe should run once, got %d", len(*probes))
}
if len(*calls) != 0 {
t.Errorf("system DNS must be left untouched, got %v", *calls)
}
if t1.systemDNSSet {
t.Error("systemDNSSet should stay false when the path is unverified")
}
}
// A real query from the interface carries a source address, and rules may match
// on it, so the probe must not be source-less.
func TestConfigureSystemDNSProbeCarriesSource(t *testing.T) {
probes := stubDNSRouting(t, nil)
recorder(t, "")
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 call, got %d", len(*probes))
}
got := (*probes)[0]
if got.source != "192.168.100.1" {
t.Errorf("probe source = %q, want the interface address %q", got.source, "192.168.100.1")
}
if got.address != "192.168.100.2" {
t.Errorf("probe address = %q, want %q", got.address, "192.168.100.2")
}
if got.inboundTag != "tun" {
t.Errorf("probe inbound tag = %q, want %q", got.inboundTag, "tun")
}
}
func TestConfigureSystemDNSAppliesResolvectl(t *testing.T) {
stubDNSRouting(t, nil)
calls := recorder(t, "")
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 := "resolvectl dns xray_tun 192.168.100.2 | " +
"resolvectl domain xray_tun ~. | " +
"resolvectl default-route xray_tun true"
if got := joined(*calls); got != want {
t.Errorf("resolvectl calls = %q, want %q", got, want)
}
}
func TestConfigureSystemDNSIdempotent(t *testing.T) {
stubDNSRouting(t, nil)
calls := recorder(t, "")
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))
}
}
// A half-applied resolver is worse than none, so a failure mid-sequence reverts.
func TestConfigureSystemDNSRollsBackOnPartialFailure(t *testing.T) {
stubDNSRouting(t, nil)
calls := recorder(t, "domain")
t1 := optedInTun()
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil {
t.Fatal("expected an error when a resolvectl step fails")
}
if t1.systemDNSSet {
t.Error("systemDNSSet should stay false after a failed takeover")
}
if t1.systemDNSDirty {
t.Error("a successful revert should not leave the resolver dirty")
}
if !strings.Contains(joined(*calls), "resolvectl revert xray_tun") {
t.Errorf("expected a revert after partial failure, got %q", joined(*calls))
}
}
// If the revert itself fails the settings may still be installed, so the state
// has to be remembered rather than silently dropped.
func TestConfigureSystemDNSRollbackFailureKeepsDirty(t *testing.T) {
stubDNSRouting(t, nil)
calls := recorder(t, "revert")
t1 := optedInTun()
t1.options.Gateway = []string{"192.168.100.1/30"}
// Make only the rollback path fail: "dns" succeeds, "domain" fails, "revert" fails.
*calls = nil
original := resolvectlRunner
defer func() { resolvectlRunner = original }()
resolvectlRunner = func(name string, args ...string) ([]byte, error) {
*calls = append(*calls, append([]string{name}, args...))
if len(args) > 0 && (args[0] == "domain" || args[0] == "revert") {
return nil, errors.New("boom")
}
return nil, nil
}
if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil {
t.Fatal("expected an error when domain fails")
}
if !t1.systemDNSDirty {
t.Error("a failed revert 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, "")
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, "resolvectl revert xray_tun") {
t.Errorf("expected the stale revert 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 TestUnsetSystemDNSReverts(t *testing.T) {
stubDNSRouting(t, nil)
calls := recorder(t, "")
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 != "resolvectl revert xray_tun" {
t.Errorf("unset calls = %q, want %q", got, "resolvectl revert xray_tun")
}
t1.unsetSystemDNS()
if len(*calls) != 1 {
t.Errorf("unsetSystemDNS must be idempotent, got %q", joined(*calls))
}
}
func TestUnsetSystemDNSKeepsDirtyWhenRevertFails(t *testing.T) {
stubDNSRouting(t, nil)
calls := recorder(t, "revert")
t1 := optedInTun()
t1.systemDNSSet = true
t1.unsetSystemDNS()
if !t1.systemDNSDirty {
t.Error("a failed revert during unset must be remembered")
}
if got := joined(*calls); !strings.Contains(got, "resolvectl revert xray_tun") {
t.Errorf("expected a revert attempt, got %q", got)
}
}
func TestSystemDNSAddrs(t *testing.T) {
tests := []struct {
name string
gateway []string
wantSource string
wantDNS string
wantOK bool
}{
{
name: "ipv4 /30",
gateway: []string{"192.168.100.1/30"},
wantSource: "192.168.100.1",
wantDNS: "192.168.100.2",
wantOK: true,
},
{
name: "ipv4 /16",
gateway: []string{"10.0.0.1/16"},
wantSource: "10.0.0.1",
wantDNS: "10.0.0.2",
wantOK: true,
},
{
name: "first ipv4 wins",
gateway: []string{"fc00::1/64", "172.18.0.1/30"},
wantSource: "172.18.0.1",
wantDNS: "172.18.0.2",
wantOK: true,
},
{
name: "no gateway",
gateway: nil,
wantOK: false,
},
{
name: "ipv6 only",
gateway: []string{"fc00::1/64"},
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
source, dnsAddr, ok := systemDNSAddrs(tt.gateway)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if !tt.wantOK {
return
}
if source.String() != tt.wantSource {
t.Errorf("source = %q, want %q", source.String(), tt.wantSource)
}
if dnsAddr.String() != tt.wantDNS {
t.Errorf("dns = %q, want %q", dnsAddr.String(), tt.wantDNS)
}
})
}
}
func TestBuildResolvectlArgs(t *testing.T) {
tests := []struct {
name string
action string
iface string
extra []string
want []string
}{
{
name: "revert",
action: "revert",
iface: "xray_tun",
want: []string{"revert", "xray_tun"},
},
{
name: "dns single",
action: "dns",
iface: "xray_tun",
extra: []string{"192.168.100.2"},
want: []string{"dns", "xray_tun", "192.168.100.2"},
},
{
name: "dns multiple",
action: "dns",
iface: "xray_tun",
extra: []string{"192.168.100.2", "fc00::2"},
want: []string{"dns", "xray_tun", "192.168.100.2", "fc00::2"},
},
{
name: "domain wildcard",
action: "domain",
iface: "xray_tun",
extra: []string{"~."},
want: []string{"domain", "xray_tun", "~."},
},
{
name: "default-route",
action: "default-route",
iface: "xray_tun",
extra: []string{"true"},
want: []string{"default-route", "xray_tun", "true"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildResolvectlArgs(tt.action, tt.iface, tt.extra...)
if len(got) != len(tt.want) {
t.Fatalf("args = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("args[%d] = %q, want %q (full: %v)", i, got[i], tt.want[i], got)
}
}
})
}
}