fix: domain names aren't resolved by tailscale's dns resolver.
This commit is contained in:
+118
-40
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user