fix: connectivity check fails on split-DNS destinations at startup

The diagnostics never got the split-DNS fix from 20097a0. That commit
  taught the dial path to resolve through the tailnet's own resolver
  (resolveDialAddr) but left getPeerFromRules on resolveAddr, so the two
  disagreed about how to look a destination up: connecting through a
  split-DNS name worked while the startup check called it unresolvable.

  Both paths now share resolveHostToIP, which honors MagicDNS, split-DNS
  routes and the DoH fallback.

  The check also resolved once at startup and cached the result for the
  process lifetime. tsnet reports Running before the netmap's DNS config
  reaches its resolver, and accept-routes is only applied after Up()
  returns, so a split-DNS name can fail for the first few seconds and
  resolve fine after. That transient failure dropped the peer permanently
  -- and when every rule failed, the goroutine returned and diagnostics
  never ran at all. Peers are re-resolved every round now, with a warm-up
  retry so the first report waits for DNS rather than racing it.

  Destinations outside the tailnet are no longer reported as failures.
  mc.lxns.net resolves fine but belongs to no peer, which is not an error,
  just not something to ping. errNotTailnetPeer separates "cannot resolve"
  from "resolved, not a peer"; only the former is retried or warned about.
  Unresolved rules now log their tag and dst, which the old message
  omitted entirely.

  Also fixed:

    * NormalizeDstAddrWithSuffix passed "host:port" to resolveAddr, so
      every existence check failed on the stray colon. Fixing that made
      the pass wait on cold-start DNS and delayed the listeners by ~5s,
      so it is now bounded by normalizeDNSBudget.

    * Peer lookup matched any AllowedIPs prefix containing the address.
      An exit node advertises 0.0.0.0/0, which contains everything, so a
      tailnet with an exit node picked the wrong peer at random depending
      on map iteration order. Default routes are skipped, the most
      specific route wins, ties break deterministically.

    * peer.AllowedIPs is a nillable pointer, dereferenced unguarded.
This commit is contained in:
iceBear67
2026-07-26 14:52:14 +08:00
parent 5dc1759d80
commit ac27db76f9
3 changed files with 311 additions and 86 deletions
+98 -67
View File
@@ -46,51 +46,113 @@ func GetMagicDNSSuffixFromStatus(st *ipnstate.Status) (string, error) {
return suffix, nil
}
// addr(ip or domain) to tailscale ip
// check the address is in the tailscale network
// errNotTailnetPeer reports that a destination resolved successfully but the
// resulting IP is not carried by any tailnet peer — an ordinary public address.
// It is distinct from a resolution failure: retrying will not change the answer.
var errNotTailnetPeer = errors.New("address is not reachable through a tailnet peer")
// resolveAddr maps a destination host (an IP literal or a domain) to the
// tailnet address of the peer that carries it, so the peer can be pinged for
// connectivity diagnostics. Names are resolved through the same tailnet-aware
// path the dial code uses (see resolveHostToIP), so MagicDNS and split-DNS
// destinations behave identically in both.
func resolveAddr(ctx context.Context, srv *tsnet.Server, addr string) (*netip.Addr, error) {
lc, err := srv.LocalClient()
ip, err := netip.ParseAddr(addr)
if err != nil {
return nil, err
ip, err = resolveHostToIP(ctx, srv, addr)
if err != nil {
return nil, err
}
}
stat, err := lc.Status(ctx)
stat, err := getCachedStatus(ctx, srv)
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
}
peer, ok := peerCarryingIP(stat, ip)
if !ok {
return nil, fmt.Errorf("%w: %s (%s)", errNotTailnetPeer, addr, ip)
}
return &peer, nil
}
// peerCarryingIP returns the tailnet address of the peer that ip belongs to,
// either because it is the peer's own address or because the peer advertises a
// route covering it.
func peerCarryingIP(stat *ipnstate.Status, ip netip.Addr) (netip.Addr, bool) {
for _, peer := range stat.Peer {
for _, peerIP := range peer.TailscaleIPs {
if peerIP == ip {
return peer.TailscaleIPs[0], true
}
}
}
// Otherwise the subnet router advertising the most specific route wins.
// Default routes are skipped: an exit node advertises 0.0.0.0/0, which
// contains every address and would otherwise shadow the real owner at
// random, since Go's map iteration order is unspecified. Ties are broken by
// the lowest tailnet address so repeated calls agree with each other.
bestBits := -1
var best netip.Addr
for _, peer := range stat.Peer {
if peer.AllowedIPs == nil || peer.AllowedIPs.IsNil() || len(peer.TailscaleIPs) == 0 {
continue
}
for _, route := range peer.AllowedIPs.All() {
if route.Bits() == 0 || !route.Contains(ip) {
continue
}
candidate := peer.TailscaleIPs[0]
if route.Bits() > bestBits || (route.Bits() == bestBits && candidate.Compare(best) < 0) {
bestBits, best = route.Bits(), candidate
}
}
}
return best, bestBits >= 0
}
// resolveHostToIP resolves a bare hostname to an address using the tailnet's
// own resolver, falling back to DNS-over-HTTPS. Both the dial path and the
// connectivity diagnostics go through here so they share one view of DNS.
//
// 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.
func resolveHostToIP(ctx context.Context, srv *tsnet.Server, host string) (netip.Addr, error) {
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 ip, 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
}
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 ip, nil
} else {
lastErr = fmt.Errorf("tailnet dns: %v; doh: %w", lastErr, derr)
}
}
return nil, errors.New(fmt.Sprintf("addr '%s' not found in tsnet", addr))
return netip.Addr{}, fmt.Errorf("resolve %q: %w", host, lastErr)
}
// resolveDialAddr resolves the host portion of a "host:port" destination to a
@@ -117,42 +179,11 @@ func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (strin
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)
ip, err := resolveHostToIP(ctx, srv, host)
if err != nil {
return addr, err
}
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)
return net.JoinHostPort(ip.String(), port), nil
}
// dnsExchange sends a single DNS question and returns the first address answer,