Files
mini-sing/dns/resolver_test.go
NeoMody 1f5612e6ad Fix gVisor ICMP interception: proxy ping through WireGuard tunnel
Fixes: admin/mini-sing#1

gVisor TUN stack intercepts ICMP echo requests and replies locally
with fake <1ms latency. Real pings to Tailscale peers never reach
the WireGuard tunnel.

Fix: PrepareConnection now checks ICMP destinations against route
rules. If the matching outbound implements ICMPPinger (e.g. Tailscale),
returns an icmpProxy DirectRouteDestination that:
1. Sends the ICMP echo through tailnet's netstack via DialPing("ping4")
2. Reads the real reply from the WireGuard tunnel
3. Rebuilds a raw IP+ICMP reply packet
4. Writes it back to the TUN via DirectRouteContext

Changes:
- adapter/outbound.go: Add ICMPPinger interface
- adapter/router.go: Add FindOutbound to ConnectionRouterEx
- route/router.go: Expose FindOutbound method
- protocol/tailscale/outbound.go: Implement ICMPPinger via tailnet.DialPing
- protocol/tun/inbound.go: ICMP proxy in PrepareConnection + icmpProxy type
- tailnet/outbound.go: Add DialPing method (wraps tnet.DialContext("ping4"))
2026-04-03 00:39:30 +08:00

218 lines
5.7 KiB
Go

package dns
import (
"net/netip"
"testing"
"github.com/miekg/dns"
)
// mockTailscale implements TailscaleLookup for testing
type mockTailscale struct {
peers map[string][]netip.Addr
}
func (m *mockTailscale) LookupTailscale(domain string) ([]netip.Addr, bool) {
// Simulate real LookupTailscale behavior: try exact match, then expand with search domains
searchDomains := []string{"tail879fb9.ts.net"}
candidates := []string{domain, domain + "."}
for _, sd := range searchDomains {
candidates = append(candidates, domain+"."+sd, domain+"."+sd+".")
}
for _, c := range candidates {
if addrs, ok := m.peers[c]; ok {
return addrs, true
}
}
return nil, false
}
func newMockTailscale() *mockTailscale {
return &mockTailscale{
peers: map[string][]netip.Addr{
"worker.tail879fb9.ts.net.": {
netip.MustParseAddr("100.96.174.121"),
netip.MustParseAddr("fd7a:115c:a1e0::5034:ae79"),
},
"m1pro.tail879fb9.ts.net.": {
netip.MustParseAddr("100.123.124.119"),
},
"station.tail879fb9.ts.net.": {
netip.MustParseAddr("100.67.38.87"),
},
},
}
}
func buildQuery(name string, qtype uint16) []byte {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(name), qtype)
msg.RecursionDesired = true
packed, _ := msg.Pack()
return packed
}
func parseResponse(t *testing.T, raw []byte) *dns.Msg {
t.Helper()
msg := new(dns.Msg)
if err := msg.Unpack(raw); err != nil {
t.Fatalf("failed to unpack response: %v", err)
}
return msg
}
func TestExchange_TailscaleFQDN(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
resp, err := r.Exchange(buildQuery("worker.tail879fb9.ts.net", dns.TypeA))
if err != nil {
t.Fatalf("Exchange failed: %v", err)
}
msg := parseResponse(t, resp)
if len(msg.Answer) == 0 {
t.Fatal("expected at least one answer")
}
a, ok := msg.Answer[0].(*dns.A)
if !ok {
t.Fatalf("expected A record, got %T", msg.Answer[0])
}
if a.A.String() != "100.96.174.121" {
t.Fatalf("expected 100.96.174.121, got %s", a.A.String())
}
}
func TestExchange_TailscaleShortName(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
// "worker" (no dots) should be resolved via LookupTailscale with search domain expansion
resp, err := r.Exchange(buildQuery("worker", dns.TypeA))
if err != nil {
t.Fatalf("Exchange failed: %v", err)
}
msg := parseResponse(t, resp)
if len(msg.Answer) == 0 {
t.Fatal("short name 'worker' should resolve to tailscale peer")
}
a, ok := msg.Answer[0].(*dns.A)
if !ok {
t.Fatalf("expected A record, got %T", msg.Answer[0])
}
if a.A.String() != "100.96.174.121" {
t.Fatalf("expected 100.96.174.121, got %s", a.A.String())
}
}
func TestExchange_TailscaleShortName_Multiple(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
tests := []struct {
name string
wantIP string
}{
{"worker", "100.96.174.121"},
{"m1pro", "100.123.124.119"},
{"station", "100.67.38.87"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := r.Exchange(buildQuery(tt.name, dns.TypeA))
if err != nil {
t.Fatalf("Exchange(%q) failed: %v", tt.name, err)
}
msg := parseResponse(t, resp)
if len(msg.Answer) == 0 {
t.Fatalf("Exchange(%q): no answers", tt.name)
}
a := msg.Answer[0].(*dns.A)
if a.A.String() != tt.wantIP {
t.Fatalf("Exchange(%q) = %s, want %s", tt.name, a.A.String(), tt.wantIP)
}
})
}
}
func TestExchange_TailscaleShortName_NotFound(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
// "nonexistent" doesn't match any peer — should fall through
// Since we have no upstream DNS server configured, this will error
_, err := r.Exchange(buildQuery("nonexistent", dns.TypeA))
if err == nil {
t.Fatal("expected error for nonexistent short name with no upstream DNS")
}
}
func TestExchange_RegularDomain_SkipsTailscale(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
// "google.com" has dots — should NOT attempt tailscale lookup, falls to upstream
_, err := r.Exchange(buildQuery("google.com", dns.TypeA))
if err == nil {
t.Fatal("expected error for regular domain with no upstream DNS")
}
// The error should be "resolver not initialized", not a tailscale error
if err.Error() != "dns: resolver not initialized" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestExchange_TailscaleFQDN_WithTrailingDot(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
// FQDN with trailing dot (as DNS wire format sends it)
resp, err := r.Exchange(buildQuery("m1pro.tail879fb9.ts.net.", dns.TypeA))
if err != nil {
t.Fatalf("Exchange failed: %v", err)
}
msg := parseResponse(t, resp)
if len(msg.Answer) == 0 {
t.Fatal("FQDN with trailing dot should resolve")
}
a := msg.Answer[0].(*dns.A)
if a.A.String() != "100.123.124.119" {
t.Fatalf("got %s, want 100.123.124.119", a.A.String())
}
}
func TestExchange_TailscaleAAAA(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
resp, err := r.Exchange(buildQuery("worker.tail879fb9.ts.net", dns.TypeAAAA))
if err != nil {
t.Fatalf("Exchange failed: %v", err)
}
msg := parseResponse(t, resp)
// Should have both A and AAAA since buildDNSResponse returns all addrs
hasAAAA := false
for _, rr := range msg.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
hasAAAA = true
if aaaa.AAAA.String() != "fd7a:115c:a1e0::5034:ae79" {
t.Fatalf("got %s, want fd7a:115c:a1e0::5034:ae79", aaaa.AAAA.String())
}
}
}
if !hasAAAA {
t.Fatal("expected AAAA record for worker")
}
}
func TestExchange_NoTailscale(t *testing.T) {
// No tailscale configured — short names should fall through
r := &Resolver{}
_, err := r.Exchange(buildQuery("worker", dns.TypeA))
if err == nil {
t.Fatal("expected error with no tailscale and no upstream")
}
}