mirror of
https://github.com/XTLS/Xray-core.git
synced 2026-09-29 20:46:59 +00:00
https://github.com/XTLS/Xray-core/issues/6454#issuecomment-4931311976 https://github.com/XTLS/Xray-core/pull/6773#issuecomment-5755516423 https://github.com/XTLS/Xray-core/pull/6807#issuecomment-5807844210
437 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|