265 lines
8.4 KiB
Go
265 lines
8.4 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/net/dns/dnsmessage"
|
|
"tailscale.com/ipn/ipnstate"
|
|
"tailscale.com/net/dns"
|
|
"tailscale.com/tsnet"
|
|
)
|
|
|
|
// magicdns suffix cache
|
|
var (
|
|
magicDNSSuffixMu sync.RWMutex
|
|
magicDNSSuffix string
|
|
)
|
|
|
|
func SetMagicDNSSuffix(raw string) {
|
|
magicDNSSuffixMu.Lock()
|
|
defer magicDNSSuffixMu.Unlock()
|
|
magicDNSSuffix = strings.Trim(raw, ".")
|
|
}
|
|
|
|
func GetMagicDNSSuffix() (string, bool) {
|
|
magicDNSSuffixMu.RLock()
|
|
defer magicDNSSuffixMu.RUnlock()
|
|
if magicDNSSuffix == "" {
|
|
return "", false
|
|
}
|
|
return magicDNSSuffix, true
|
|
}
|
|
|
|
func GetMagicDNSSuffixFromStatus(st *ipnstate.Status) (string, error) {
|
|
suffix := st.CurrentTailnet.MagicDNSSuffix
|
|
suffix = strings.Trim(suffix, ".")
|
|
if suffix == "" {
|
|
return "", errors.New("magic dns suffix not found in status")
|
|
}
|
|
return suffix, nil
|
|
}
|
|
|
|
// addr(ip or domain) to tailscale ip
|
|
// check the address is in the tailscale network
|
|
func resolveAddr(ctx context.Context, srv *tsnet.Server, addr string) (*netip.Addr, error) {
|
|
lc, err := srv.LocalClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stat, err := lc.Status(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if ip, err := netip.ParseAddr(addr); err == nil {
|
|
for _, peer := range stat.Peer {
|
|
for _, ipRange := range peer.AllowedIPs.All() {
|
|
if ipRange.Contains(ip) {
|
|
return &peer.TailscaleIPs[0], nil
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
suffix, ok := GetMagicDNSSuffix()
|
|
if ok {
|
|
if !strings.HasSuffix(addr, suffix) {
|
|
dnsMgr, ok := srv.Sys().DNSManager.GetOK()
|
|
if !ok {
|
|
return nil, errors.New("DNS manager not available")
|
|
}
|
|
ipaddr, err := resolveHostViaResolver(ctx, dnsMgr, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resolveAddr(ctx, srv, ipaddr.String())
|
|
}
|
|
}
|
|
// addr is tailscale domain, resolve it
|
|
for _, peer := range stat.Peer {
|
|
dnsName := strings.TrimSuffix(peer.DNSName, ".")
|
|
if dnsName == addr {
|
|
return &peer.TailscaleIPs[0], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, errors.New(fmt.Sprintf("addr '%s' not found in tsnet", addr))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
if dnsMgr, ok := srv.Sys().DNSManager.GetOK(); ok {
|
|
for _, name := range candidates {
|
|
ip, err := resolveHostViaResolver(ctx, dnsMgr, name)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return net.JoinHostPort(ip.String(), port), nil
|
|
}
|
|
} else {
|
|
lastErr = errors.New("DNS manager not available")
|
|
}
|
|
|
|
// Fallback: resolve public names via DNS-over-HTTPS when the tailnet
|
|
// resolver couldn't (no working system DNS on the host, or a name outside
|
|
// the tailnet's split-DNS routes). Only the original host is queried — DoH
|
|
// can't resolve tailnet-internal MagicDNS names.
|
|
if dohEnabled() {
|
|
if ip, derr := resolveHostViaDoH(ctx, host); derr == nil {
|
|
return net.JoinHostPort(ip.String(), port), nil
|
|
} else {
|
|
lastErr = fmt.Errorf("tailnet dns: %v; doh: %w", lastErr, derr)
|
|
}
|
|
}
|
|
|
|
return addr, fmt.Errorf("resolve %q: %w", host, lastErr)
|
|
}
|
|
|
|
// dnsExchange sends a single DNS question and returns the first address answer,
|
|
// or a CNAME target if one is present instead. It abstracts the transport so the
|
|
// Tailscale resolver and the DNS-over-HTTPS fallback (see doh.go) can share the
|
|
// CNAME-chasing logic in resolveHostChase.
|
|
type dnsExchange func(ctx context.Context, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, 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 resolveHostChase(ctx, host, 0, tailnetExchange(resolver))
|
|
}
|
|
|
|
// tailnetExchange returns a dnsExchange backed by the Tailscale DNS resolver.
|
|
func tailnetExchange(r *dns.Manager) dnsExchange {
|
|
return func(ctx context.Context, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error) {
|
|
queryBytes, err := buildDNSQuery(name, qType)
|
|
if err != nil {
|
|
return netip.Addr{}, "", 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)
|
|
}
|
|
return parseDNSAnswer(respBytes)
|
|
}
|
|
}
|
|
|
|
// resolveHostChase resolves host to an address by issuing A then AAAA questions
|
|
// through exchange and following CNAME chains (up to maxCNAMEChase levels deep).
|
|
// 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.
|
|
func resolveHostChase(ctx context.Context, host string, depth int, exchange dnsExchange) (netip.Addr, error) {
|
|
const maxCNAMEChase = 8
|
|
if depth > maxCNAMEChase {
|
|
return netip.Addr{}, fmt.Errorf("CNAME chain too deep for %s", host)
|
|
}
|
|
|
|
name, err := dnsmessage.NewName(host + ".")
|
|
if err != nil {
|
|
return netip.Addr{}, fmt.Errorf("invalid hostname %s: %w", host, err)
|
|
}
|
|
|
|
var cnameTarget string
|
|
for _, qType := range []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA} {
|
|
ip, cname, err := exchange(ctx, name, qType)
|
|
if err != nil {
|
|
return netip.Addr{}, err
|
|
}
|
|
if ip.IsValid() {
|
|
return ip, nil
|
|
}
|
|
if cname != "" {
|
|
cnameTarget = cname
|
|
}
|
|
}
|
|
|
|
if cnameTarget != "" {
|
|
return resolveHostChase(ctx, cnameTarget, depth+1, exchange)
|
|
}
|
|
|
|
return netip.Addr{}, fmt.Errorf("no A/AAAA record found for %s", host)
|
|
}
|
|
|
|
// buildDNSQuery packs a single-question DNS query message for name/qType.
|
|
func buildDNSQuery(name dnsmessage.Name, qType dnsmessage.Type) ([]byte, 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 nil, fmt.Errorf("failed to pack DNS query: %w", err)
|
|
}
|
|
return queryBytes, nil
|
|
}
|
|
|
|
// parseDNSAnswer unpacks a DNS response and returns the first A/AAAA address, or
|
|
// a CNAME target if one is present instead of an address record.
|
|
func parseDNSAnswer(respBytes []byte) (netip.Addr, string, error) {
|
|
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
|
|
}
|