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"))
218 lines
5.7 KiB
Go
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")
|
|
}
|
|
}
|