WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
15 changed files with 508 additions and 176 deletions
Showing only changes of commit 364b952d62 - Show all commits
+22 -23
View File
@@ -27,7 +27,7 @@ import (
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
"tailscale.com/util/linuxfw"
"tailscale.com/util/mak"
@@ -55,7 +55,7 @@ type egressProxy struct {
tsClient *local.Client // never nil
netmapChan chan *netmap.NetworkMap // chan to receive netmap updates on
netmapChan chan netmapState // chan to receive netmap state updates on
podIPv4 string // never empty string, currently only IPv4 is supported
@@ -87,7 +87,7 @@ type httpClient interface {
// - the mounted egress config has changed
// - the proxy's tailnet IP addresses have changed
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
func (ep *egressProxy) run(ctx context.Context, nm *netmap.NetworkMap, opts egressProxyRunOpts) error {
func (ep *egressProxy) run(ctx context.Context, nm netmapState, opts egressProxyRunOpts) error {
ep.configure(opts)
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
@@ -136,7 +136,7 @@ type egressProxyRunOpts struct {
kc kubeclient.Client
tsClient *local.Client
stateSecret string
netmapChan chan *netmap.NetworkMap
netmapChan chan netmapState
podIPv4 string
tailnetAddrs []netip.Prefix
}
@@ -165,7 +165,7 @@ func (ep *egressProxy) configure(opts egressProxyRunOpts) {
// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current
// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such
// as failed firewall update
func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
func (ep *egressProxy) sync(ctx context.Context, nm netmapState) error {
cfgs, err := ep.getConfigs()
if err != nil {
return fmt.Errorf("error retrieving egress service configs: %w", err)
@@ -186,16 +186,15 @@ func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
return nil
}
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
// Netmap must not be nil.
func (ep *egressProxy) addrsHaveChanged(nm *netmap.NetworkMap) bool {
return !reflect.DeepEqual(ep.tailnetAddrs, nm.SelfNode.Addresses())
// addrsHaveChanged returns true if the provided netmap state contains tailnet address change for this proxy node.
func (ep *egressProxy) addrsHaveChanged(nm netmapState) bool {
return !views.SliceEqual(views.SliceOf(ep.tailnetAddrs), nm.self.Addresses())
}
// syncEgressConfigs adds and deletes firewall rules to match the desired
// configuration. It uses the provided status to determine what is currently
// applied and updates the status after a successful sync.
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm *netmap.NetworkMap) (*egressservices.Status, error) {
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm netmapState) (*egressservices.Status, error) {
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
return nil, nil
}
@@ -234,7 +233,7 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
// family.
for _, t := range tailnetTargetIPs {
var local netip.Addr
for _, pfx := range nm.SelfNode.Addresses().All() {
for _, pfx := range nm.self.Addresses().All() {
if !pfx.IsSingleIP() {
continue
}
@@ -424,7 +423,7 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
// setStatus writes egress proxy's currently configured firewall to the state
// Secret and updates proxy's tailnet addresses.
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm *netmap.NetworkMap) error {
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm netmapState) error {
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
if status == nil {
status = &egressservices.Status{}
@@ -447,7 +446,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
return fmt.Errorf("error patching state Secret: %w", err)
}
ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
return nil
}
@@ -457,7 +456,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
// doesn't.
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *netmap.NetworkMap) (addrs []netip.Addr, err error) {
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm netmapState) (addrs []netip.Addr, err error) {
if svc.TailnetTarget.IP != "" {
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
if err != nil {
@@ -473,8 +472,8 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *net
if svc.TailnetTarget.FQDN == "" {
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
}
if nm == nil {
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
if !nm.self.Valid() {
log.Printf("netmap state is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
return addrs, nil
}
egressAddrs, err := resolveTailnetFQDN(nm, svc.TailnetTarget.FQDN)
@@ -501,26 +500,26 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *net
return addrs, nil
}
// shouldResync parses netmap update and returns true if the update contains
// shouldResync parses netmap state update and returns true if the update contains
// changes for which the egress proxy's firewall should be reconfigured.
func (ep *egressProxy) shouldResync(nm *netmap.NetworkMap) bool {
if nm == nil {
func (ep *egressProxy) shouldResync(nm netmapState) bool {
if !nm.self.Valid() {
return false
}
// If proxy's tailnet addresses have changed, resync.
if !reflect.DeepEqual(nm.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
if !views.SliceEqual(nm.self.Addresses(), views.SliceOf(ep.tailnetAddrs)) {
log.Printf("node addresses have changed, trigger egress config resync")
ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
return true
}
// If the IPs for any of the egress services configured via FQDN have
// changed, resync.
for fqdn, ips := range ep.targetFQDNs {
for _, nn := range nm.Peers {
for nn := range nm.peers() {
if equalFQDNs(nn.Name(), fqdn) {
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
if !views.SliceEqual(views.SliceOf(ips), nn.Addresses()) {
log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice())
return true
}
+175 -51
View File
@@ -120,6 +120,7 @@ import (
"errors"
"fmt"
"io/fs"
"iter"
"log"
"math"
"net"
@@ -136,11 +137,12 @@ import (
"syscall"
"time"
"github.com/benbjohnson/immutable"
"golang.org/x/sys/unix"
"tailscale.com/client/local"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
kubeutils "tailscale.com/k8s-operator"
"tailscale.com/kube/authkey"
healthz "tailscale.com/kube/health"
@@ -151,7 +153,7 @@ import (
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/deephash"
"tailscale.com/util/dnsname"
"tailscale.com/util/linuxfw"
@@ -168,6 +170,137 @@ func getAutoAdvertiseBool() bool {
return defaultBool("TS_EXPERIMENTAL_SERVICE_AUTO_ADVERTISEMENT", true)
}
const containerbootWatchMask = ipn.NotifyInitialStatus |
ipn.NotifyPeerChanges |
ipn.NotifyNoNetMap
func notifyState(n ipn.Notify) (_ ipn.State, ok bool) {
if n.State != nil {
return *n.State, true
}
if n.InitialStatus != nil && n.InitialStatus.BackendState != "" {
if state, ok := ipn.StateFromString(n.InitialStatus.BackendState); ok {
return state, true
}
}
return ipn.NoState, false
}
var netmapStatePeerIDHasher = immutable.NewHasher(tailcfg.NodeID(0))
type netmapState struct {
self tailcfg.NodeView
peersByID *immutable.Map[tailcfg.NodeID, tailcfg.NodeView]
peersByName *immutable.Map[string, tailcfg.NodeView] // keyed by tailcfg.Node.Name when NodeID is unavailable
certDomains views.Slice[string]
dnsExtraRecords views.Slice[tailcfg.DNSRecord]
}
func (s netmapState) updateFromNotify(n ipn.Notify) netmapState {
if n.InitialStatus != nil {
s = s.updateFromStatus(n.InitialStatus)
}
if n.SelfChange != nil {
s.self = n.SelfChange.View()
}
for _, p := range n.PeersChanged {
s = s.upsertPeer(p.View())
}
for _, id := range n.PeersRemoved {
if s.peersByID != nil {
s.peersByID = s.peersByID.Delete(id)
}
}
return s
}
func (s netmapState) updateFromStatus(st *ipnstate.Status) netmapState {
s.certDomains = views.SliceOf(st.CertDomains)
s.dnsExtraRecords = views.SliceOf(st.ExtraRecords)
if st.Self != nil {
s.self = nodeFromPeerStatus(st.Self).View()
}
if len(st.Peer) != 0 {
s.peersByID = nil
s.peersByName = nil
for _, ps := range st.Peer {
s = s.upsertPeer(nodeFromPeerStatus(ps).View())
}
}
return s
}
func (s netmapState) upsertPeer(n tailcfg.NodeView) netmapState {
if !n.Valid() {
return s
}
if s.peersByID == nil {
s.peersByID = immutable.NewMap[tailcfg.NodeID, tailcfg.NodeView](netmapStatePeerIDHasher)
}
if s.peersByName == nil {
s.peersByName = immutable.NewMap[string, tailcfg.NodeView](nil)
}
if n.ID() != 0 {
s.peersByID = s.peersByID.Set(n.ID(), n)
if name := n.Name(); name != "" {
s.peersByName = s.peersByName.Delete(name)
}
return s
}
if n.Name() != "" {
s.peersByName = s.peersByName.Set(n.Name(), n)
}
return s
}
func nodeFromPeerStatus(ps *ipnstate.PeerStatus) *tailcfg.Node {
if ps == nil {
return nil
}
n := &tailcfg.Node{
ID: ps.NodeID,
StableID: ps.ID,
Name: ps.DNSName,
Key: ps.PublicKey,
}
for _, ip := range ps.TailscaleIPs {
n.Addresses = append(n.Addresses, netip.PrefixFrom(ip, ip.BitLen()))
}
if ps.AllowedIPs != nil {
n.AllowedIPs = ps.AllowedIPs.AsSlice()
}
return n
}
func (s netmapState) peers() iter.Seq[tailcfg.NodeView] {
return func(yield func(tailcfg.NodeView) bool) {
if s.peersByID != nil {
it := s.peersByID.Iterator()
for {
_, p, ok := it.Next()
if !ok {
break
}
if !yield(p) {
return
}
}
}
if s.peersByName != nil {
it := s.peersByName.Iterator()
for {
_, p, ok := it.Next()
if !ok {
break
}
if !yield(p) {
return
}
}
}
}
}
func main() {
if err := run(); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
@@ -308,7 +441,7 @@ func run() error {
}
}
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState|ipn.NotifyInitialHealthState|ipn.NotifyRateLimit)
w, err := client.WatchIPNBus(bootCtx, containerbootWatchMask|ipn.NotifyInitialPrefs|ipn.NotifyInitialHealthState)
if err != nil {
return fmt.Errorf("failed to watch tailscaled for updates: %w", err)
}
@@ -348,7 +481,7 @@ func run() error {
if err := tailscaleUp(bootCtx, cfg); err != nil {
return fmt.Errorf("failed to auth tailscale: %w", err)
}
w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
w, err = client.WatchIPNBus(bootCtx, containerbootWatchMask)
if err != nil {
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
}
@@ -368,8 +501,8 @@ authLoop:
return fmt.Errorf("failed to read from tailscaled: %w", err)
}
if n.State != nil {
switch *n.State {
if state, ok := notifyState(n); ok {
switch state {
case ipn.NeedsLogin:
if isOneStepConfig(cfg) {
// This could happen if this is the first time tailscaled was run for this
@@ -405,7 +538,7 @@ authLoop:
// deadline to continue monitoring for changes.
break authLoop
default:
log.Printf("tailscaled in state %q, waiting", *n.State)
log.Printf("tailscaled in state %q, waiting", state)
}
}
@@ -460,7 +593,7 @@ authLoop:
}
}
w, err = client.WatchIPNBus(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
w, err = client.WatchIPNBus(ctx, containerbootWatchMask)
if err != nil {
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
}
@@ -539,7 +672,7 @@ authLoop:
failedResolveAttempts++
}
var egressSvcsNotify chan *netmap.NetworkMap
var egressSvcsNotify chan netmapState
notifyChan := make(chan ipn.Notify)
errChan := make(chan error)
go func() {
@@ -553,12 +686,7 @@ authLoop:
}
}
}()
// Peer set changes (Add/Remove) no longer ride on the IPN bus; poll
// periodically so egress FQDN resolution and peer-aware work picks
// them up. SelfChange covers prompt self changes.
const peerPollInterval = 15 * time.Second
peerPoll := time.NewTicker(peerPollInterval)
defer peerPoll.Stop()
var nmState netmapState
var wg sync.WaitGroup
runLoop:
@@ -576,19 +704,17 @@ runLoop:
return fmt.Errorf("failed to read from tailscaled: %w", err)
case err := <-cfgWatchErrChan:
return fmt.Errorf("failed to watch tailscaled config: %w", err)
case <-peerPoll.C:
processNetmap = true
case n := <-notifyChan:
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
if n.State != nil && *n.State != ipn.Running {
nmState = nmState.updateFromNotify(n)
if state, ok := notifyState(n); ok && state != ipn.Running {
// Something's gone wrong and we've left the authenticated state.
// Our container image never recovered gracefully from this, and the
// control flow required to make it work now is hard. So, just crash
// the container and rely on the container runtime to restart us,
// whereupon we'll go through initial auth again.
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", state)
}
if n.SelfChange != nil {
if n.InitialStatus != nil || n.SelfChange != nil || len(n.PeersChanged) != 0 || len(n.PeersRemoved) != 0 || len(n.PeerChangedPatch) != 0 {
processNetmap = true
}
case <-tc:
@@ -618,13 +744,12 @@ runLoop:
if !processNetmap {
continue
}
nm, err := fetchNetMap(ctx, client)
if err != nil {
log.Printf("error fetching netmap: %v", err)
self := nmState.self
if !self.Valid() {
continue
}
if nm != nil {
addrs = nm.SelfNode.Addresses().AsSlice()
{
addrs = self.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
@@ -636,14 +761,14 @@ runLoop:
// Kubernetes Secret to clean up tailnet nodes
// for proxies whose route setup continuously
// fails.
deviceID := nm.SelfNode.StableID()
deviceID := self.StableID()
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) {
if err := kc.storeDeviceID(ctx, nm.SelfNode.StableID()); err != nil {
if err := kc.storeDeviceID(ctx, deviceID); err != nil {
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
}
}
if cfg.TailnetTargetFQDN != "" {
egressAddrs, err := resolveTailnetFQDN(nm, cfg.TailnetTargetFQDN)
egressAddrs, err := resolveTailnetFQDN(nmState, cfg.TailnetTargetFQDN)
if err != nil {
log.Print(err.Error())
break
@@ -699,7 +824,10 @@ runLoop:
backendAddrs = newBackendAddrs
}
if cfg.ServeConfigPath != "" {
cd := certDomainFromNetmap(nm)
var cd string
if nmState.certDomains.Len() != 0 {
cd = nmState.certDomains.At(0)
}
if cd == "" {
cd = kubetypes.ValueNoHTTPS
}
@@ -742,9 +870,9 @@ runLoop:
// set up ensures that the operator does not
// advertize endpoints of broken proxies.
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
deviceEndpoints := []any{nm.SelfNode.Name(), nm.SelfNode.Addresses()}
deviceEndpoints := []any{self.Name(), self.Addresses()}
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceEndpoints, &deviceEndpoints) {
if err := kc.storeDeviceEndpoints(ctx, nm.SelfNode.Name(), nm.SelfNode.Addresses().AsSlice()); err != nil {
if err := kc.storeDeviceEndpoints(ctx, self.Name(), addrs); err != nil {
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
}
}
@@ -773,7 +901,7 @@ runLoop:
}
if egressSvcsNotify != nil {
egressSvcsNotify <- nm
egressSvcsNotify <- nmState
}
}
if !startupTasksDone {
@@ -795,7 +923,7 @@ runLoop:
// will crash this node.
if cfg.EgressProxiesCfgPath != "" {
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
egressSvcsNotify = make(chan *netmap.NetworkMap)
egressSvcsNotify = make(chan netmapState)
opts := egressProxyRunOpts{
cfgPath: cfg.EgressProxiesCfgPath,
nfr: nfr,
@@ -807,7 +935,7 @@ runLoop:
tailnetAddrs: addrs,
}
go func() {
if err := ep.run(ctx, nm, opts); err != nil {
if err := ep.run(ctx, nmState, opts); err != nil {
egressSvcsErrorChan <- err
}
}()
@@ -987,30 +1115,26 @@ func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) {
}
}
// fetchNetMap fetches the current netmap from tailscaled via the
// "current-netmap" localapi debug action. The debug action's payload
// shape is intentionally not part of any stable API; containerboot
// reads its own internal-package types out of it. New external consumers
// should not rely on this — see [local.Client.Status] and friends.
func fetchNetMap(ctx context.Context, lc *local.Client) (*netmap.NetworkMap, error) {
return local.GetDebugResultJSON[*netmap.NetworkMap](ctx, lc, "current-netmap")
}
// resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
// can be either a peer device, a Tailscale Service, or a 4via6 synthesized
// DNS name (e.g. "10-1-0-5-via-7.tailnet.ts.net").
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
func resolveTailnetFQDN(nm netmapState, fqdn string) ([]netip.Prefix, error) {
dnsFQDN, err := dnsname.ToFQDN(fqdn)
if err != nil {
return nil, fmt.Errorf("error parsing %q as FQDN: %w", fqdn, err)
}
// Check all peer devices first.
for _, p := range nm.Peers {
var ret []netip.Prefix
for p := range nm.peers() {
if strings.EqualFold(p.Name(), dnsFQDN.WithTrailingDot()) {
return p.Addresses().AsSlice(), nil
ret = p.Addresses().AsSlice()
break
}
}
if ret != nil {
return ret, nil
}
// If not found yet, check for a matching Tailscale Service.
if svcIPs := serviceIPsFromNetMap(nm, dnsFQDN); len(svcIPs) != 0 {
@@ -1020,7 +1144,7 @@ func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, err
// If not found yet, check for a matching 4via6 DNS name.
if addr, ok := resolveViaDomain(dnsFQDN); ok {
prefix := netip.PrefixFrom(addr, addr.BitLen())
for _, nn := range nm.Peers {
for nn := range nm.peers() {
for _, allowedIP := range nn.AllowedIPs().All() {
if allowedIP.Contains(addr) {
return []netip.Prefix{prefix}, nil
@@ -1036,9 +1160,9 @@ func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, err
// serviceIPsFromNetMap returns all IPs of a Tailscale Service if its FQDN is
// found in the netmap. Note that Tailscale Services are not a first-class
// object in the netmap, so we guess based on DNS ExtraRecords and AllowedIPs.
func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Prefix {
func serviceIPsFromNetMap(nm netmapState, fqdn dnsname.FQDN) []netip.Prefix {
var extraRecords []tailcfg.DNSRecord
for _, rec := range nm.DNS.ExtraRecords {
for _, rec := range nm.dnsExtraRecords.All() {
recFQDN, err := dnsname.ToFQDN(rec.Name)
if err != nil {
continue
@@ -1060,7 +1184,7 @@ func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Pref
continue
}
ipPrefix := netip.PrefixFrom(ip, ip.BitLen())
for _, ps := range nm.Peers {
for ps := range nm.peers() {
for _, allowedIP := range ps.AllowedIPs().All() {
if allowedIP == ipPrefix {
prefixes = append(prefixes, ipPrefix)
+63 -80
View File
@@ -35,12 +35,13 @@ import (
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/netmap"
"tailscale.com/types/key"
)
const configFileAuthKey = "some-auth-key"
@@ -52,6 +53,7 @@ func TestContainerBoot(t *testing.T) {
t.Fatalf("Building containerboot: %v", err)
}
egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.2")
egressStatusUpdated := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.3")
metricsURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
@@ -71,12 +73,6 @@ func TestContainerBoot(t *testing.T) {
// Waits below to be true before proceeding to the next phase.
Notify *ipn.Notify
// If non-nil, install this NetMap on the fake LocalAPI before
// sending Notify. This is the replacement for the old
// Notify.NetMap field; reactive consumers fetch the current
// netmap via /localapi/v0/netmap on their own.
NetMap *netmap.NetworkMap
// WantCmds is the commands that containerboot should run in this phase.
WantCmds []string
@@ -392,19 +388,12 @@ func TestContainerBoot(t *testing.T) {
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
PeersChanged: []*tailcfg.Node{
{
StableID: tailcfg.StableNodeID("ipv6ID"),
Name: "ipv6-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
}).View(),
},
},
},
WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
@@ -646,13 +635,6 @@ func TestContainerBoot(t *testing.T) {
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("newID"),
Name: "new-name.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "new-name.test.ts.net.",
@@ -1114,19 +1096,12 @@ func TestContainerBoot(t *testing.T) {
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
PeersChanged: []*tailcfg.Node{
{
StableID: tailcfg.StableNodeID("fooID"),
Name: "foo.tailnetxyz.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
}).View(),
},
},
},
WantKubeSecret: map[string]string{
@@ -1141,6 +1116,23 @@ func TestContainerBoot(t *testing.T) {
egressSvcTerminateURL(env.localAddrPort): 200,
},
},
{
Notify: &ipn.Notify{
PeersChanged: []*tailcfg.Node{{
StableID: tailcfg.StableNodeID("fooID"),
Name: "foo.tailnetxyz.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.3/32")},
}},
},
WantKubeSecret: map[string]string{
"egress-services": string(mustJSON(t, egressStatusUpdated)),
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net.",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
kubetypes.KeyCapVer: capver,
},
},
},
}
},
@@ -1295,17 +1287,8 @@ func TestContainerBoot(t *testing.T) {
t.Fatalf("phase %d: updating mtime for %q: %v", i, path, err)
}
}
nmForFake := p.NetMap
if nmForFake == nil && p.Notify != nil && p.Notify.SelfChange != nil {
// Synthesize a minimal netmap from SelfChange so
// containerboot's NetMap() fetch returns
// something usable when the test only set Notify.
nmForFake = &netmap.NetworkMap{
SelfNode: p.Notify.SelfChange.View(),
}
}
if nmForFake != nil {
env.lapi.SetNetMap(nmForFake)
if p.Notify != nil && p.Notify.InitialStatus == nil {
p.Notify.InitialStatus = statusFromNotify(p.Notify)
}
env.lapi.Notify(p.Notify)
if p.Signal != nil {
@@ -1499,7 +1482,6 @@ type localAPI struct {
sync.Mutex
cond *sync.Cond
notify *ipn.Notify
netmap *netmap.NetworkMap // served by /localapi/v0/netmap
}
func (lc *localAPI) Start() error {
@@ -1536,44 +1518,45 @@ func (lc *localAPI) Notify(n *ipn.Notify) {
lc.cond.Broadcast()
}
// SetNetMap installs the netmap that the fake /localapi/v0/netmap endpoint
// will return.
func (lc *localAPI) SetNetMap(nm *netmap.NetworkMap) {
lc.Lock()
defer lc.Unlock()
lc.netmap = nm
func statusFromNotify(n *ipn.Notify) *ipnstate.Status {
st := new(ipnstate.Status)
if n.State != nil {
st.BackendState = n.State.String()
}
if n.SelfChange != nil {
st.Self = peerStatusFromNode(n.SelfChange.View())
}
if len(n.PeersChanged) != 0 {
st.Peer = map[key.NodePublic]*ipnstate.PeerStatus{}
for _, p := range n.PeersChanged {
pv := p.View()
st.Peer[pv.Key()] = peerStatusFromNode(pv)
}
}
return st
}
func peerStatusFromNode(n tailcfg.NodeView) *ipnstate.PeerStatus {
ps := &ipnstate.PeerStatus{
ID: n.StableID(),
NodeID: n.ID(),
PublicKey: n.Key(),
DNSName: n.Name(),
}
for _, p := range n.Addresses().All() {
if p.IsSingleIP() {
ps.TailscaleIPs = append(ps.TailscaleIPs, p.Addr())
}
}
if n.AllowedIPs().Len() != 0 {
v := n.AllowedIPs()
ps.AllowedIPs = &v
}
return ps
}
func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/localapi/v0/netmap":
w.Header().Set("Content-Type", "application/json")
lc.Lock()
nm := lc.netmap
lc.Unlock()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
}
json.NewEncoder(w).Encode(nm)
return
case "/localapi/v0/debug":
// containerboot fetches the netmap via the "current-netmap"
// debug action; serve it like /localapi/v0/netmap above.
if r.URL.Query().Get("action") != "current-netmap" {
http.Error(w, "unsupported debug action", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
lc.Lock()
nm := lc.netmap
lc.Unlock()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
}
json.NewEncoder(w).Encode(nm)
return
case "/localapi/v0/serve-config":
switch r.Method {
case "GET":
-8
View File
@@ -24,7 +24,6 @@ import (
"tailscale.com/kube/kubetypes"
klc "tailscale.com/kube/localclient"
"tailscale.com/kube/services"
"tailscale.com/types/netmap"
)
// watchServeConfigChanges watches path for changes, and when it sees one, reads
@@ -142,13 +141,6 @@ func refreshAdvertiseServices(ctx context.Context, sc *ipn.ServeConfig, lc klc.L
return nil
}
func certDomainFromNetmap(nm *netmap.NetworkMap) string {
if len(nm.DNS.CertDomains) == 0 {
return ""
}
return nm.DNS.CertDomains[0]
}
func updateServeConfig(ctx context.Context, sc *ipn.ServeConfig, certDomain string, lc klc.LocalClient) error {
if !isValidHTTPSConfig(certDomain, sc) {
return nil
+50
View File
@@ -1317,6 +1317,56 @@ func (nu *countingNetmapUpdater) UpdateFullNetmap(nm *netmap.NetworkMap) {
nu.full.Add(1)
}
type countingDeltaNetmapUpdater struct {
countingNetmapUpdater
delta atomic.Int64
}
func (nu *countingDeltaNetmapUpdater) UpdateNetmapDelta([]netmap.NodeMutation) bool {
nu.delta.Add(1)
return true
}
func TestExistingPeerReplacementHandledIncrementally(t *testing.T) {
nu := &countingDeltaNetmapUpdater{}
ms := newTestMapSession(t, nu)
ctx := t.Context()
peer := &tailcfg.Node{
ID: 1,
StableID: "peer",
Name: "peer.example.ts.net.",
Key: key.NewNode().Public(),
DiscoKey: key.NewDisco().Public(),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
AllowedIPs: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
Hostinfo: (&tailcfg.Hostinfo{}).View(),
}
if err := ms.handleNonKeepAliveMapResponse(ctx, &tailcfg.MapResponse{
Node: &tailcfg.Node{Name: "self.example.ts.net."},
Peers: []*tailcfg.Node{peer},
}, false); err != nil {
t.Fatal(err)
}
if got := nu.full.Load(); got != 1 {
t.Fatalf("full updates after initial response = %d; want 1", got)
}
replacement := peer.Clone()
replacement.AllowedIPs = append(replacement.AllowedIPs, netip.MustParsePrefix("100.64.0.2/32"))
if err := ms.handleNonKeepAliveMapResponse(ctx, &tailcfg.MapResponse{
PeersChanged: []*tailcfg.Node{replacement},
}, false); err != nil {
t.Fatal(err)
}
if got := nu.full.Load(); got != 1 {
t.Errorf("full updates after route-changing peer replacement = %d; want 1", got)
}
if got := nu.delta.Load(); got != 1 {
t.Errorf("delta updates after route-changing peer replacement = %d; want 1", got)
}
}
// tests (*mapSession).patchifyPeersChanged; smaller tests are in TestPeerChangeDiff
func TestPatchifyPeersChanged(t *testing.T) {
hi := (&tailcfg.Hostinfo{}).View()
+1 -1
View File
@@ -164,4 +164,4 @@
});
};
}
# nix-direnv cache busting line: sha256-rueM6HBKbu8WBw4atwubJD3c0O95zAkUVeOkARDiX18=
# nix-direnv cache busting line: sha256-pXdOQRF8EpZ+fFYhsEgAT04k3h88gdOZ42e0AMeV4Wc=
+2 -2
View File
@@ -4,7 +4,7 @@
"sri": "sha256-uskKJHUzzIQ74VuzoQKrlz+6tCY/YKnv+BjJduPwt6Q="
},
"vendor": {
"goModSum": "sha256-MqRZjijiKUKNgFmZmw6bMOfrU6HLh3iJYe+7lCKb0Uw=",
"sri": "sha256-rueM6HBKbu8WBw4atwubJD3c0O95zAkUVeOkARDiX18="
"goModSum": "sha256-nI9Dzn2PeLKj9mnskoHhwZNix+6H7pA4nlB75sDAn1U=",
"sri": "sha256-pXdOQRF8EpZ+fFYhsEgAT04k3h88gdOZ42e0AMeV4Wc="
}
}
+1
View File
@@ -162,6 +162,7 @@ require (
github.com/armon/go-metrics v0.4.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/beevik/ntp v0.3.0 // indirect
github.com/benbjohnson/immutable v0.4.3
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
github.com/bombsimon/wsl/v4 v4.2.1 // indirect
+2
View File
@@ -187,6 +187,8 @@ github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r
github.com/beevik/ntp v0.2.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg=
github.com/beevik/ntp v0.3.0 h1:xzVrPrE4ziasFXgBVBZJDP0Wg/KpMwk2KHJ4Ba8GrDw=
github.com/beevik/ntp v0.3.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg=
github.com/benbjohnson/immutable v0.4.3 h1:GYHcksoJ9K6HyAUpGxwZURrbTkXA0Dh4otXGqbhdrjA=
github.com/benbjohnson/immutable v0.4.3/go.mod h1:qJIKKSmdqz1tVzNtst1DZzvaqOU1onk1rc03IeM3Owk=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+21 -8
View File
@@ -5,6 +5,7 @@ package ipn
import (
"fmt"
"slices"
"strings"
"time"
@@ -35,15 +36,27 @@ const (
// ID tokens used by the Android client.
const GoogleIDTokenType = "ts_android_google_login"
var stateStrings = [...]string{
"NoState",
"InUseOtherUser",
"NeedsLogin",
"NeedsMachineAuth",
"Stopped",
"Starting",
"Running",
}
func (s State) String() string {
return [...]string{
"NoState",
"InUseOtherUser",
"NeedsLogin",
"NeedsMachineAuth",
"Stopped",
"Starting",
"Running"}[s]
return stateStrings[s]
}
// StateFromString parses s as a State string value.
func StateFromString(s string) (_ State, ok bool) {
i := slices.Index(stateStrings[:], s)
if i == -1 {
return NoState, false
}
return State(i), true
}
// EngineStatus contains WireGuard engine stats.
+34 -2
View File
@@ -1417,6 +1417,7 @@ func (b *LocalBackend) updateStatusLocked(sb *ipnstate.StatusBuilder) {
}
if nm != nil {
s.CertDomains = append([]string(nil), nm.DNS.CertDomains...)
s.ExtraRecords = append([]tailcfg.DNSRecord(nil), nm.DNS.ExtraRecords...)
s.MagicDNSSuffix = nm.MagicDNSSuffix()
if s.CurrentTailnet == nil {
s.CurrentTailnet = &ipnstate.TailnetStatus{}
@@ -2325,6 +2326,7 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
defer b.mu.Unlock()
cn := b.currentNode()
needsAuthReconfig := netmapDeltaNeedsAuthReconfig(cn, muts)
cn.UpdateNetmapDelta(muts)
// Dispatch Upsert/Remove per-peer to magicsock, and any per-field
@@ -2348,6 +2350,9 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
}
}
ms.UpdateNetmapDelta(muts)
if needsAuthReconfig {
b.authReconfigLocked()
}
// If auto exit nodes are enabled and our exit node went offline,
// we need to schedule picking a new one.
@@ -2411,6 +2416,33 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
return true
}
func netmapDeltaNeedsAuthReconfig(cn *nodeBackend, muts []netmap.NodeMutation) bool {
for _, m := range muts {
switch m := m.(type) {
case netmap.NodeMutationUpsert:
old, ok := cn.NodeByID(m.Node.ID())
if !ok {
continue
}
if peerRouteConfigChanged(old, m.Node) {
return true
}
}
}
return false
}
func peerRouteConfigChanged(old, new tailcfg.NodeView) bool {
return old.Key() != new.Key() ||
old.DiscoKey() != new.DiscoKey() ||
!views.SliceEqual(old.AllowedIPs(), new.AllowedIPs()) ||
old.Expired() != new.Expired() ||
old.IsJailed() != new.IsJailed() ||
old.IsWireGuardOnly() != new.IsWireGuardOnly() ||
old.SelfNodeV4MasqAddrForThisPeer() != new.SelfNodeV4MasqAddrForThisPeer() ||
old.SelfNodeV6MasqAddrForThisPeer() != new.SelfNodeV6MasqAddrForThisPeer()
}
// UpdatePacketFilter implements [controlclient.PacketFilterUpdater].
//
// It is called by the controlclient when a MapResponse carries a new packet
@@ -2506,7 +2538,7 @@ func mutationsAreWorthyOfRecalculatingSuggestedExitNode(muts []netmap.NodeMutati
// [tailcfg.PeerChange] for use in [ipn.Notify.PeerChangedPatch]. Multiple
// mutations against the same node are merged into a single PeerChange.
//
// Add/Remove mutations are skipped (they ride
// Upsert/Remove mutations are skipped (they ride
// [ipn.Notify.PeersChanged]/[ipn.Notify.PeersRemoved]). Any other mutation
// type that doesn't fit a [tailcfg.PeerChange] causes ok=false; the caller
// should fall back to a full netmap rebuild.
@@ -5662,7 +5694,7 @@ func (b *LocalBackend) authReconfigLocked() {
cn := b.currentNode()
nm := cn.NetMap()
nm := cn.netMapWithPeers()
if nm == nil {
b.logf("[v1] authReconfig: netmap not yet valid. Skipping.")
return
+43
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"math/rand/v2"
"net/netip"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -1573,6 +1574,48 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
}
}
func TestEngineReconfigOnPeerRouteDelta(t *testing.T) {
connect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: true}, WantRunningSet: true}
peerAddr := netip.MustParsePrefix("100.64.1.1/32")
vipAddr := netip.MustParsePrefix("100.99.99.99/32")
peer := makePeer(1, withName("node-1"), withAddresses(peerAddr))
peerStruct := peer.AsStruct()
peerStruct.AllowedIPs = []netip.Prefix{peerAddr}
peer = peerStruct.View()
nm := buildNetmapWithPeers(
makePeer(2, withName("node-2"), withAddresses(netip.MustParsePrefix("100.64.1.2/32"))),
peer,
)
lb, engine, cc := newLocalBackendWithMockEngineAndControl(t, false)
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(nm)
replacement := nm.Peers[0].AsStruct()
replacement.AllowedIPs = append(replacement.AllowedIPs, vipAddr)
if !lb.UpdateNetmapDelta([]netmap.NodeMutation{netmap.NodeMutationUpsert{Node: replacement.View()}}) {
t.Fatal("UpdateNetmapDelta = false, want true")
}
cfg := engine.Config()
if cfg == nil {
t.Fatal("engine config is nil")
}
for _, peer := range cfg.Peers {
if peer.PublicKey != replacement.Key {
continue
}
if !slices.Contains(peer.AllowedIPs, vipAddr) {
t.Fatalf("peer AllowedIPs = %v; want %v", peer.AllowedIPs, vipAddr)
}
return
}
t.Fatalf("engine config missing peer %v", replacement.Key.ShortString())
}
// TestSendPreservesAuthURL tests that wgengine updates arriving in the middle of
// processing an auth URL doesn't result in the auth URL being cleared.
func TestSendPreservesAuthURL(t *testing.T) {
+3
View File
@@ -73,6 +73,9 @@ type Status struct {
// trailing periods, and without any "_acme-challenge." prefix.
CertDomains []string
// ExtraRecords contains extra DNS records to add to the DNS resolver.
ExtraRecords []tailcfg.DNSRecord
// Peer is the state of each peer, keyed by each peer's current public key.
Peer map[key.NodePublic]*PeerStatus
+1 -1
View File
@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-rueM6HBKbu8WBw4atwubJD3c0O95zAkUVeOkARDiX18=
# nix-direnv cache busting line: sha256-pXdOQRF8EpZ+fFYhsEgAT04k3h88gdOZ42e0AMeV4Wc=
+90
View File
@@ -914,6 +914,96 @@ func TestIncrementalMapUpdatePeersRemoved(t *testing.T) {
d2.MustCleanShutdown(t)
}
// TestIncrementalMapUpdatePeerAllowedIPsReachability verifies that an incremental
// peer upsert changing a peer's AllowedIPs reprograms the local WireGuard config.
// This covers VIP additions at runtime, where the VIP route is not reachable
// before the map mutation but is reachable over TSMP afterward.
func TestIncrementalMapUpdatePeerAllowedIPsReachability(t *testing.T) {
tstest.Shard(t)
tstest.Parallel(t)
env := NewTestEnv(t)
n1 := NewTestNode(t, env)
d1 := n1.StartDaemon()
defer d1.MustCleanShutdown(t)
n1.AwaitListening()
n1.MustUp()
n1.AwaitRunning()
n2 := NewTestNode(t, env)
d2 := n2.StartDaemon()
defer d2.MustCleanShutdown(t)
n2.AwaitListening()
n2.MustUp()
n2.AwaitRunning()
n1Status := n1.MustStatus()
n2Status := n2.MustStatus()
tnode1 := env.Control.Node(n1Status.Self.PublicKey)
if tnode1 == nil {
t.Fatalf("control has no node for %v", n1Status.Self.PublicKey)
}
tnode2 := env.Control.Node(n2Status.Self.PublicKey)
if tnode2 == nil {
t.Fatalf("control has no node for %v", n2Status.Self.PublicKey)
}
vip := netip.MustParseAddr("100.99.99.99")
vipPrefix := netip.PrefixFrom(vip, vip.BitLen())
if err := n1.Tailscale("ping", "--tsmp", "--c=1", "--timeout=5s", n2.AwaitIP4().String()).Run(); err != nil {
t.Fatalf("initial ping n1 -> n2: %v", err)
}
if err := n1.Tailscale("ping", "--tsmp", "--c=1", "--timeout=1s", vip.String()).Run(); err == nil {
t.Fatalf("ping n1 -> n2 VIP %v before AllowedIPs delta succeeded unexpectedly", vip)
}
mr, err := env.Control.MapResponse(&tailcfg.MapRequest{NodeKey: tnode1.Key})
if err != nil {
t.Fatalf("MapResponse: %v", err)
}
var replacement *tailcfg.Node
for _, p := range mr.Peers {
if p.ID == tnode2.ID {
replacement = p.Clone()
break
}
}
if replacement == nil {
t.Fatalf("MapResponse for n1 has no peer n2")
}
replacement.AllowedIPs = append(replacement.AllowedIPs, vipPrefix)
if !env.Control.AddRawMapResponse(tnode1.Key, &tailcfg.MapResponse{
PeersChanged: []*tailcfg.Node{replacement},
}) {
t.Fatalf("failed to add map response")
}
if err := tstest.WaitFor(5*time.Second, func() error {
st := n1.MustStatus()
p, ok := st.Peer[tnode2.Key]
if !ok {
return fmt.Errorf("node 1 doesn't see node 2 as a peer")
}
if p.AllowedIPs == nil {
return fmt.Errorf("node 1 sees node 2 with no AllowedIPs")
}
for _, allowedIP := range p.AllowedIPs.All() {
if allowedIP == vipPrefix {
return nil
}
}
return fmt.Errorf("node 1 sees node 2 AllowedIPs %v; want %v", p.AllowedIPs, vipPrefix)
}); err != nil {
t.Fatal(err)
}
if err := n1.Tailscale("ping", "--tsmp", "--c=1", "--timeout=5s", vip.String()).Run(); err != nil {
t.Fatalf("ping n1 -> n2 VIP %v after AllowedIPs delta: %v", vip, err)
}
}
func TestNodeAddressIPFields(t *testing.T) {
tstest.Shard(t)
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/7008")