Files
iceBear67 ac27db76f9 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.
2026-07-26 14:52:14 +08:00

273 lines
7.6 KiB
Go

package core
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/netip"
"strings"
"time"
"tailscale.com/client/local"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
)
func StartTimeWatchDog(ctx context.Context, logger *slog.Logger) <-chan struct{} {
logger.Info("starting watchdog")
ch := make(chan struct{}, 1)
go func() {
lastUnix := time.Now().Unix()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
nowUnix := time.Now().Unix()
diff := nowUnix - lastUnix
if diff > 300 {
logger.Warn("system time jump detected(wake up from sleep?)",
slog.Int64("jump_seconds", diff),
)
ch <- struct{}{}
logger.Debug("signal sent, watchdog exiting...")
return
}
lastUnix = nowUnix
}
}
}()
return ch
}
// getPeerFromRules maps every connect rule's destination onto the tailnet peer
// that carries it. Alongside the peers it reports how many rules could not be
// resolved at all; those are retryable, unlike destinations that resolve to an
// address outside the tailnet (an ordinary public host), which are skipped for
// good. warn selects whether unresolved rules are logged as warnings — during
// startup the tailnet resolver may not have its split-DNS routes yet, so the
// first few rounds stay quiet.
func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger, warn bool) ([]netip.Addr, int) {
peerSet := make(map[netip.Addr]struct{})
unresolved := 0
for tag, rrs := range rules {
for _, rule := range rrs {
rule := rule
tag := tag
ap, _, err := net.SplitHostPort(rule.DstAddr)
if err != nil {
logger.Debug("error parsing rule", "tag", tag, "dst", rule.DstAddr, "err", err)
continue
}
addr, err := resolveAddr(ctx, srv, ap)
if err != nil {
if errors.Is(err, errNotTailnetPeer) {
logger.Debug("destination is outside the tailnet, skipping diagnostics",
"tag", tag, "dst", rule.DstAddr, "err", err)
continue
}
unresolved++
if warn {
logger.Warn("failed to resolve address", "tag", tag, "dst", rule.DstAddr, "err", err)
} else {
logger.Debug("failed to resolve address (tailnet DNS may still be settling)",
"tag", tag, "dst", rule.DstAddr, "err", err)
}
continue
}
logger.Debug("address found", "dst_addr", rule.DstAddr, "tag", tag, "address", addr)
peerSet[*addr] = struct{}{}
}
}
var result []netip.Addr
for peer := range peerSet {
result = append(result, peer)
}
return result, unresolved
}
func peerConnectivityLogic(ctx context.Context, lc *local.Client, relativePeers []netip.Addr, logger *slog.Logger) {
for _, peer := range relativePeers {
loLog := logger.With("peer", peer)
ping, err := func() (*ipnstate.PingResult, error) {
cnclCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ping, err := lc.Ping(cnclCtx, peer, tailcfg.PingDisco)
return ping, err
}()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
loLog.Warn("connectivity: peer ping timeout")
} else {
loLog.Warn("connectivity: failed to ping peer", "err", err)
}
continue
}
peerInfo, err := lc.WhoIs(ctx, peer.String())
if err != nil {
loLog.Warn("failed to get peer info", "err", err)
} else {
loLog = loLog.With("name", peerInfo.Node.ComputedName)
}
var connect string
if ping.DERPRegionCode == "" {
connect = "direct"
} else {
connect = ping.DERPRegionCode
}
loLog.Info("connectivity: peer pinged",
"latency", fmt.Sprintf("%.2fms", ping.LatencySeconds*1000),
"connect", connect,
)
}
}
const (
// peerDiagInterval is how often connectivity to each peer is re-checked.
peerDiagInterval = 120 * time.Second
// A tsnet server reports Running before the netmap's DNS configuration has
// been programmed into its resolver, and accept-routes is only applied once
// the server is up — so at startup a split-DNS destination can briefly fail
// to resolve even though it resolves fine moments later. Retry a handful of
// times before reporting anything as broken.
peerDiagWarmupTries = 6
peerDiagWarmupDelay = 2 * time.Second
)
func StartPeerConnectivityDiagnostics(ctx context.Context, logger *slog.Logger, srv *tsnet.Server, rules map[string][]ConnectRule) {
go func() {
lc, err := srv.LocalClient()
if err != nil {
logger.Error("failed to get local client", "err", err)
return
}
// Warm-up: keep retrying while destinations are still unresolvable, and
// only escalate to a warning on the final attempt.
var peers []netip.Addr
for try := 1; ; try++ {
last := try >= peerDiagWarmupTries
var unresolved int
peers, unresolved = getPeerFromRules(ctx, srv, rules, logger, last)
if unresolved == 0 || last {
break
}
logger.Debug("waiting for tailnet DNS before diagnosing peers",
"unresolved", unresolved, "attempt", try)
select {
case <-ctx.Done():
return
case <-time.After(peerDiagWarmupDelay):
}
}
logger.Debug("Peers loaded", "count", len(peers))
ticker := time.NewTicker(peerDiagInterval)
defer ticker.Stop()
for {
peerConnectivityLogic(ctx, lc, peers, logger)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
// Re-resolve every round: destinations that failed at startup
// recover on their own, and split-DNS records may point elsewhere
// than they did two minutes ago.
peers, _ = getPeerFromRules(ctx, srv, rules, logger, true)
}
}()
}
func getSelfTsnetAddr(srv *tsnet.Server) netip.Addr {
ip4, ip6 := srv.TailscaleIPs()
ip := ip4
if !ip.IsValid() {
ip = ip6
}
return ip
}
func NormalizeDstAddrWithSuffix(ctx context.Context, srv *tsnet.Server, dst string) (string, bool, error) {
host, port, err := net.SplitHostPort(dst)
if err != nil {
return dst, false, err
}
if _, err := netip.ParseAddr(host); err == nil {
return dst, false, nil
}
suffix, ok := GetMagicDNSSuffix()
if !ok {
return dst, false, nil
}
qualified := host + "." + suffix
normalized := net.JoinHostPort(qualified, port)
// check domain exists before use. resolveAddr takes a bare host — passing
// the "host:port" form made every lookup here fail on the stray colon.
if strings.Contains(host, ".") {
_, err = resolveAddr(ctx, srv, qualified)
if err != nil {
return dst, false, nil
}
}
return normalized, true, nil
}
// normalizeDNSBudget caps how long the whole normalization pass may spend
// waiting on DNS. It runs before the connectors start listening, and on a cold
// start the tailnet resolver needs a few seconds before it answers — without a
// bound the listeners would not come up until then. A name that cannot be
// checked in time simply keeps its configured form, which is the same
// conclusion the check reaches for anything that is not a MagicDNS name.
const normalizeDNSBudget = 2 * time.Second
func NormalizeConnectRulesDstAddr(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger) {
ctx, cancel := context.WithTimeout(ctx, normalizeDNSBudget)
defer cancel()
for tag, rrs := range rules {
for i := range rrs {
rule := &rrs[i]
normalized, changed, err := NormalizeDstAddrWithSuffix(ctx, srv, rule.DstAddr)
if err != nil {
logger.Debug("failed to normalize dst_addr",
slog.String("tag", tag),
slog.String("dst", rule.DstAddr),
slog.String("error", err.Error()),
)
continue
}
if changed {
logger.Debug("dst_addr normalized with MagicDNS suffix",
slog.String("tag", tag),
slog.String("original", rule.DstAddr),
slog.String("normalized", normalized),
)
rule.DstAddr = normalized
}
}
}
}