fix: domain names aren't resolved by tailscale's dns resolver.

This commit is contained in:
iceBear67
2026-07-21 01:18:01 +08:00
parent e1cfd61d46
commit 20097a02a1
3 changed files with 141 additions and 43 deletions
+118 -40
View File
@@ -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
}
+12 -2
View File
@@ -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))
}
+11 -1
View File
@@ -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)
}