Files
Xray-core/proxy/tun/tun_linux_dns_test.go
T
patternihaandClaude Opus 5.5 4ec4fb8aab TUN inbound: Rename to autoSystemWfpBlockLeak and autoSystemDnsToGateway
autoSystemWFP becomes autoSystemWfpBlockLeak, saying that the WFP filters
block leaks, and autoSystemDNS becomes autoSystemDnsToGateway, saying
where it points the system DNS, so that pointing the system DNS at the
gateway on Windows later would fit the same name. Their config fields
keep their numbers; neither was released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 02:31:00 +03:30

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"},
AutoSystemDnsToGateway: 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.AutoSystemDnsToGateway = 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)
}
}
})
}
}