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.
136 lines
3.5 KiB
Go
136 lines
3.5 KiB
Go
package core
|
|
|
|
import (
|
|
"net/netip"
|
|
"testing"
|
|
|
|
"tailscale.com/ipn/ipnstate"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/views"
|
|
)
|
|
|
|
// peerStatus builds a PeerStatus with the given tailnet address and advertised
|
|
// routes. Passing no routes leaves AllowedIPs nil, as it is for peers that
|
|
// advertise nothing.
|
|
func peerStatus(tailIP string, routes ...string) *ipnstate.PeerStatus {
|
|
ps := &ipnstate.PeerStatus{
|
|
TailscaleIPs: []netip.Addr{netip.MustParseAddr(tailIP)},
|
|
}
|
|
if len(routes) > 0 {
|
|
prefixes := make([]netip.Prefix, 0, len(routes))
|
|
for _, r := range routes {
|
|
prefixes = append(prefixes, netip.MustParsePrefix(r))
|
|
}
|
|
s := views.SliceOf(prefixes)
|
|
ps.AllowedIPs = &s
|
|
}
|
|
return ps
|
|
}
|
|
|
|
func statusWithPeers(peers ...*ipnstate.PeerStatus) *ipnstate.Status {
|
|
st := &ipnstate.Status{Peer: make(map[key.NodePublic]*ipnstate.PeerStatus, len(peers))}
|
|
for _, p := range peers {
|
|
st.Peer[key.NewNode().Public()] = p
|
|
}
|
|
return st
|
|
}
|
|
|
|
func TestPeerCarryingIP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
peers []*ipnstate.PeerStatus
|
|
ip string
|
|
want string // "" means no peer expected
|
|
}{
|
|
{
|
|
name: "peer's own address",
|
|
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1", "100.64.0.1/32")},
|
|
ip: "100.64.0.1",
|
|
want: "100.64.0.1",
|
|
},
|
|
{
|
|
name: "subnet router carries a LAN address",
|
|
peers: []*ipnstate.PeerStatus{
|
|
peerStatus("100.64.0.2", "100.64.0.2/32", "10.0.0.0/24"),
|
|
peerStatus("100.64.0.3", "100.64.0.3/32"),
|
|
},
|
|
ip: "10.0.0.7",
|
|
want: "100.64.0.2",
|
|
},
|
|
{
|
|
// An exit node advertises 0.0.0.0/0, which Contains every address.
|
|
// Matching it would pick a peer at random out of map iteration order.
|
|
name: "exit node does not shadow the real subnet router",
|
|
peers: []*ipnstate.PeerStatus{
|
|
peerStatus("100.64.0.9", "0.0.0.0/0", "::/0"),
|
|
peerStatus("100.64.0.2", "10.0.0.0/24"),
|
|
},
|
|
ip: "10.0.0.7",
|
|
want: "100.64.0.2",
|
|
},
|
|
{
|
|
name: "most specific route wins",
|
|
peers: []*ipnstate.PeerStatus{
|
|
peerStatus("100.64.0.4", "10.0.0.0/8"),
|
|
peerStatus("100.64.0.5", "10.0.0.0/24"),
|
|
},
|
|
ip: "10.0.0.7",
|
|
want: "100.64.0.5",
|
|
},
|
|
{
|
|
name: "equal routes break the tie deterministically",
|
|
peers: []*ipnstate.PeerStatus{
|
|
peerStatus("100.64.0.8", "10.0.0.0/24"),
|
|
peerStatus("100.64.0.6", "10.0.0.0/24"),
|
|
},
|
|
ip: "10.0.0.7",
|
|
want: "100.64.0.6",
|
|
},
|
|
{
|
|
name: "public address belongs to no peer",
|
|
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1", "10.0.0.0/24")},
|
|
ip: "1.1.1.1",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "peer without AllowedIPs is skipped, not dereferenced",
|
|
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1")},
|
|
ip: "10.0.0.7",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "exit node alone still does not match",
|
|
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.9", "0.0.0.0/0")},
|
|
ip: "1.1.1.1",
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := statusWithPeers(tt.peers...)
|
|
// Run repeatedly: map iteration order is unspecified, so a result
|
|
// that depends on it shows up as a flake here.
|
|
for range 20 {
|
|
got, ok := peerCarryingIP(st, netip.MustParseAddr(tt.ip))
|
|
if tt.want == "" {
|
|
if ok {
|
|
t.Fatalf("peerCarryingIP() = %v, true; want no match", got)
|
|
}
|
|
continue
|
|
}
|
|
if !ok {
|
|
t.Fatalf("peerCarryingIP() = _, false; want %s", tt.want)
|
|
}
|
|
if got.String() != tt.want {
|
|
t.Fatalf("peerCarryingIP() = %s, want %s", got, tt.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|