From 20097a02a1ac59510dc1b15c2795df2d5e97f1fd Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Tue, 21 Jul 2026 01:18:01 +0800 Subject: [PATCH] fix: domain names aren't resolved by tailscale's dns resolver. --- core/dns.go | 158 ++++++++++++++++++++++++++++++++++------------ core/tcp.go | 14 +++- core/udp_relay.go | 12 +++- 3 files changed, 141 insertions(+), 43 deletions(-) diff --git a/core/dns.go b/core/dns.go index 97a310e..d1375ff 100644 --- a/core/dns.go +++ b/core/dns.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net" "net/netip" "strings" "sync" @@ -73,7 +74,7 @@ func resolveAddr(ctx context.Context, srv *tsnet.Server, addr string) (*netip.Ad if !ok { return nil, errors.New("DNS manager not available") } - ipaddr, err := resolveHostViaResolver(dnsMgr, addr) + ipaddr, err := resolveHostViaResolver(ctx, dnsMgr, addr) if err != nil { return nil, err } @@ -92,14 +93,64 @@ func resolveAddr(ctx context.Context, srv *tsnet.Server, addr string) (*netip.Ad return nil, errors.New(fmt.Sprintf("addr '%s' not found in tsnet", addr)) } -// resolveHostViaResolver resolves a hostname to a netip.Addr using the -// Tailscale DNS resolver. It queries A and AAAA records in a single -// message and follows CNAME chains (up to 8 levels deep). -func resolveHostViaResolver(resolver *dns.Manager, host string) (netip.Addr, error) { - return resolveHostWithDepth(resolver, host, 0) +// resolveDialAddr resolves the host portion of a "host:port" destination to a +// concrete "ip:port" using the tailnet's own DNS resolver. +// +// tsnet's Server.Dial only resolves MagicDNS names that are baked into the +// network map; for everything else it falls back to the host OS resolver, which +// has no knowledge of the tailnet's split-DNS configuration (custom search +// domains such as *.homelab.ice whose queries are routed to a nameserver +// reachable over Tailscale). By resolving through srv.Sys().DNSManager here — +// which honors MagicDNS and split-DNS routes exactly like quad-100 would — and +// dialing the resulting IP, split-DNS destinations resolve correctly. +// +// Literal IP destinations are returned unchanged. When tailnet resolution +// fails, the original address is returned together with the error so the caller +// may still fall back to dialing the name directly (e.g. via the system +// resolver for ordinary public names). +func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (string, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return addr, err + } + if _, err := netip.ParseAddr(host); err == nil { + return addr, nil // already ip:port, nothing to resolve + } + + dnsMgr, ok := srv.Sys().DNSManager.GetOK() + if !ok { + return addr, errors.New("DNS manager not available") + } + + // Names to try, in order. A bare single-label name additionally gets the + // MagicDNS suffix appended so short tailnet hostnames still resolve; a name + // that already contains a dot (an FQDN, including split-DNS suffixes) is + // queried as-is. + candidates := []string{host} + if suffix, ok := GetMagicDNSSuffix(); ok && !strings.Contains(host, ".") { + candidates = append(candidates, host+"."+suffix) + } + + var lastErr error + for _, name := range candidates { + ip, err := resolveHostViaResolver(ctx, dnsMgr, name) + if err != nil { + lastErr = err + continue + } + return net.JoinHostPort(ip.String(), port), nil + } + return addr, fmt.Errorf("resolve %q via tailnet DNS: %w", host, lastErr) } -func resolveHostWithDepth(r *dns.Manager, host string, depth int) (netip.Addr, error) { +// resolveHostViaResolver resolves a hostname to a netip.Addr using the +// Tailscale DNS resolver. It queries A then AAAA records and follows CNAME +// chains (up to 8 levels deep). +func resolveHostViaResolver(ctx context.Context, resolver *dns.Manager, host string) (netip.Addr, error) { + return resolveHostWithDepth(ctx, resolver, host, 0) +} + +func resolveHostWithDepth(ctx context.Context, r *dns.Manager, host string, depth int) (netip.Addr, error) { const maxCNAMEChase = 8 if depth > maxCNAMEChase { return netip.Addr{}, fmt.Errorf("CNAME chain too deep for %s", host) @@ -110,45 +161,72 @@ func resolveHostWithDepth(r *dns.Manager, host string, depth int) (netip.Addr, e return netip.Addr{}, fmt.Errorf("invalid hostname %s: %w", host, err) } - msg := dnsmessage.Message{ - Header: dnsmessage.Header{RecursionDesired: true}, - Questions: []dnsmessage.Question{ - {Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}, - }, - } - queryBytes, err := msg.Pack() - if err != nil { - return netip.Addr{}, fmt.Errorf("failed to pack DNS query: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - respBytes, err := r.Query(ctx, queryBytes, "udp", netip.AddrPort{}) - if err != nil { - return netip.Addr{}, fmt.Errorf("DNS resolution failed for %s: %w", host, err) - } - - var resp dnsmessage.Message - if err := resp.Unpack(respBytes); err != nil { - return netip.Addr{}, fmt.Errorf("failed to unpack DNS response: %w", err) - } - + // Query A first (MagicDNS hands out an IPv4 for tailnet peers), then AAAA so + // IPv6-only split-DNS hosts still resolve. A CNAME seen in either answer is + // chased once no address record is found. var cnameTarget string - for _, ans := range resp.Answers { - switch r := ans.Body.(type) { - case *dnsmessage.AResource: - if ip := netip.AddrFrom4(r.A); ip.IsValid() { - return ip, nil - } - case *dnsmessage.CNAMEResource: - cnameTarget = strings.TrimSuffix(r.CNAME.String(), ".") + for _, qType := range []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA} { + ip, cname, err := queryResolver(ctx, r, name, qType) + if err != nil { + return netip.Addr{}, err + } + if ip.IsValid() { + return ip, nil + } + if cname != "" { + cnameTarget = cname } } - // Follow CNAME if no direct A found + // Follow CNAME if no direct address record was found. if cnameTarget != "" { - return resolveHostWithDepth(r, cnameTarget, depth+1) + return resolveHostWithDepth(ctx, r, cnameTarget, depth+1) } return netip.Addr{}, fmt.Errorf("no A/AAAA record found for %s", host) } + +// queryResolver sends a single question of the given type to the Tailscale DNS +// resolver and returns the first address answer, or a CNAME target if one is +// present instead. +func queryResolver(ctx context.Context, r *dns.Manager, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error) { + msg := dnsmessage.Message{ + Header: dnsmessage.Header{RecursionDesired: true}, + Questions: []dnsmessage.Question{ + {Name: name, Type: qType, Class: dnsmessage.ClassINET}, + }, + } + queryBytes, err := msg.Pack() + if err != nil { + return netip.Addr{}, "", fmt.Errorf("failed to pack DNS query: %w", err) + } + + qctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + respBytes, err := r.Query(qctx, queryBytes, "udp", netip.AddrPort{}) + if err != nil { + return netip.Addr{}, "", fmt.Errorf("DNS resolution failed for %s: %w", strings.TrimSuffix(name.String(), "."), err) + } + + var resp dnsmessage.Message + if err := resp.Unpack(respBytes); err != nil { + return netip.Addr{}, "", fmt.Errorf("failed to unpack DNS response: %w", err) + } + + var cname string + for _, ans := range resp.Answers { + switch body := ans.Body.(type) { + case *dnsmessage.AResource: + if ip := netip.AddrFrom4(body.A); ip.IsValid() { + return ip, "", nil + } + case *dnsmessage.AAAAResource: + if ip := netip.AddrFrom16(body.AAAA); ip.IsValid() { + return ip, "", nil + } + case *dnsmessage.CNAMEResource: + cname = strings.TrimSuffix(body.CNAME.String(), ".") + } + } + return netip.Addr{}, cname, nil +} diff --git a/core/tcp.go b/core/tcp.go index 02e82a5..106bee1 100644 --- a/core/tcp.go +++ b/core/tcp.go @@ -105,7 +105,17 @@ func runTCPConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, l func handleTCPConnect(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ConnectRule, logger *slog.Logger) { clog := logger.With(slog.String("local_client", conn.RemoteAddr().String())) - tsConn, err := dialTsnet(ctx, srv, "tcp", rule.DstAddr) + // Resolve MagicDNS / split-DNS names through the tailnet resolver before + // dialing; tsnet's own Dial cannot resolve custom split-DNS suffixes. + dstAddr := rule.DstAddr + if resolved, rerr := resolveDialAddr(ctx, srv, rule.DstAddr); rerr != nil { + clog.Debug("failed to resolve dst via tailnet dns, dialing name directly", + slog.String("dst", rule.DstAddr), slog.String("error", rerr.Error())) + } else { + dstAddr = resolved + } + + tsConn, err := dialTsnet(ctx, srv, "tcp", dstAddr) if err != nil { clog.Error("failed to dial tailscale", "error", err) conn.Close() @@ -118,7 +128,7 @@ func handleTCPConnect(ctx context.Context, srv *tsnet.Server, conn net.Conn, rul }) defer stop() - clog.Info("accepted connection", slog.String("dst_addr", rule.DstAddr)) + clog.Info("accepted connection", slog.String("dst_addr", rule.DstAddr), slog.String("resolved", dstAddr)) toConn, toTs := pipeConns(conn, tsConn) clog.Info("connection closed", slog.Int64("ts_rx_bytes", toTs), slog.Int64("ts_tx_bytes", toConn)) } diff --git a/core/udp_relay.go b/core/udp_relay.go index 7731745..00b51f5 100644 --- a/core/udp_relay.go +++ b/core/udp_relay.go @@ -102,7 +102,17 @@ func (r *udpRelay) run(ctx context.Context) { r.mu.Unlock() var dialed net.Conn if inTsnet { - dialed, err = dialTsnet(ctx, r.srv, "udp", r.dialAddr) + // Resolve MagicDNS / split-DNS names through the tailnet + // resolver before dialing; tsnet's own Dial cannot resolve + // custom split-DNS suffixes. + dialAddr := r.dialAddr + if resolved, rerr := resolveDialAddr(ctx, r.srv, r.dialAddr); rerr != nil { + r.logger.Debug("failed to resolve dst via tailnet dns, dialing name directly", + slog.String("dst", r.dialAddr), slog.String("error", rerr.Error())) + } else { + dialAddr = resolved + } + dialed, err = dialTsnet(ctx, r.srv, "udp", dialAddr) } else { dialed, err = dialUDP(ctx, r.dialAddr) }