fix: domain names aren't resolved by tailscale's dns resolver.
This commit is contained in:
+116
-38
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -73,7 +74,7 @@ func resolveAddr(ctx context.Context, srv *tsnet.Server, addr string) (*netip.Ad
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("DNS manager not available")
|
return nil, errors.New("DNS manager not available")
|
||||||
}
|
}
|
||||||
ipaddr, err := resolveHostViaResolver(dnsMgr, addr)
|
ipaddr, err := resolveHostViaResolver(ctx, dnsMgr, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
return nil, errors.New(fmt.Sprintf("addr '%s' not found in tsnet", addr))
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveHostViaResolver resolves a hostname to a netip.Addr using the
|
// resolveDialAddr resolves the host portion of a "host:port" destination to a
|
||||||
// Tailscale DNS resolver. It queries A and AAAA records in a single
|
// concrete "ip:port" using the tailnet's own DNS resolver.
|
||||||
// message and follows CNAME chains (up to 8 levels deep).
|
//
|
||||||
func resolveHostViaResolver(resolver *dns.Manager, host string) (netip.Addr, error) {
|
// tsnet's Server.Dial only resolves MagicDNS names that are baked into the
|
||||||
return resolveHostWithDepth(resolver, host, 0)
|
// 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
|
const maxCNAMEChase = 8
|
||||||
if depth > maxCNAMEChase {
|
if depth > maxCNAMEChase {
|
||||||
return netip.Addr{}, fmt.Errorf("CNAME chain too deep for %s", host)
|
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)
|
return netip.Addr{}, fmt.Errorf("invalid hostname %s: %w", host, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := dnsmessage.Message{
|
// Query A first (MagicDNS hands out an IPv4 for tailnet peers), then AAAA so
|
||||||
Header: dnsmessage.Header{RecursionDesired: true},
|
// IPv6-only split-DNS hosts still resolve. A CNAME seen in either answer is
|
||||||
Questions: []dnsmessage.Question{
|
// chased once no address record is found.
|
||||||
{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)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cnameTarget string
|
var cnameTarget string
|
||||||
for _, ans := range resp.Answers {
|
for _, qType := range []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA} {
|
||||||
switch r := ans.Body.(type) {
|
ip, cname, err := queryResolver(ctx, r, name, qType)
|
||||||
case *dnsmessage.AResource:
|
if err != nil {
|
||||||
if ip := netip.AddrFrom4(r.A); ip.IsValid() {
|
return netip.Addr{}, err
|
||||||
|
}
|
||||||
|
if ip.IsValid() {
|
||||||
return ip, nil
|
return ip, nil
|
||||||
}
|
}
|
||||||
case *dnsmessage.CNAMEResource:
|
if cname != "" {
|
||||||
cnameTarget = strings.TrimSuffix(r.CNAME.String(), ".")
|
cnameTarget = cname
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow CNAME if no direct A found
|
// Follow CNAME if no direct address record was found.
|
||||||
if cnameTarget != "" {
|
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)
|
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) {
|
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()))
|
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 {
|
if err != nil {
|
||||||
clog.Error("failed to dial tailscale", "error", err)
|
clog.Error("failed to dial tailscale", "error", err)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -118,7 +128,7 @@ func handleTCPConnect(ctx context.Context, srv *tsnet.Server, conn net.Conn, rul
|
|||||||
})
|
})
|
||||||
defer stop()
|
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)
|
toConn, toTs := pipeConns(conn, tsConn)
|
||||||
clog.Info("connection closed", slog.Int64("ts_rx_bytes", toTs), slog.Int64("ts_tx_bytes", toConn))
|
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()
|
r.mu.Unlock()
|
||||||
var dialed net.Conn
|
var dialed net.Conn
|
||||||
if inTsnet {
|
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 {
|
} else {
|
||||||
dialed, err = dialUDP(ctx, r.dialAddr)
|
dialed, err = dialUDP(ctx, r.dialAddr)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user