diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index b70d71cb9..684e721c4 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -311,18 +311,49 @@ func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap { if filt == nil { return nil } - addrs := nb.netMap.GetAddresses() - for i := range addrs.Len() { - a := addrs.At(i) - if !a.IsSingleIP() { - continue - } - dst := a.Addr() - if dst.BitLen() == src.BitLen() { // match on family - return filt.CapsWithValues(src, dst) + + var dsts []netip.Addr + for _, a := range nb.netMap.GetAddresses().All() { + if a.IsSingleIP() { + dsts = append(dsts, a.Addr()) } } - return nil + for _, addr := range nb.serviceVIPAddrs() { + dsts = append(dsts, addr) + } + + var out tailcfg.PeerCapMap + for _, dst := range dsts { + if dst.BitLen() != src.BitLen() { + continue + } + cm := filt.CapsWithValues(src, dst) + if len(cm) == 0 { + continue + } + if out == nil { + out = cm + continue + } + for k, v := range cm { + out[k] = append(out[k], v...) + } + } + return out +} + +// serviceVIPAddrs returns the IP addresses of VIP services this node is +// hosting, as delivered by the control plane via NodeAttrServiceHost. +func (nb *nodeBackend) serviceVIPAddrs() []netip.Addr { + svcMap := nb.netMap.GetVIPServiceIPMap() + if len(svcMap) == 0 { + return nil + } + var addrs []netip.Addr + for _, svcAddrs := range svcMap { + addrs = append(addrs, svcAddrs...) + } + return addrs } // PeerHasCap reports whether the peer contains the given capability string, diff --git a/ipn/ipnlocal/node_backend_test.go b/ipn/ipnlocal/node_backend_test.go index f1f38dae6..0bcfb5528 100644 --- a/ipn/ipnlocal/node_backend_test.go +++ b/ipn/ipnlocal/node_backend_test.go @@ -5,15 +5,19 @@ import ( "context" + "encoding/json" "errors" + "net/netip" "testing" "time" + "go4.org/netipx" "tailscale.com/tailcfg" "tailscale.com/tstest" "tailscale.com/types/netmap" "tailscale.com/types/ptr" "tailscale.com/util/eventbus" + "tailscale.com/wgengine/filter" ) func TestNodeBackendReadiness(t *testing.T) { @@ -126,6 +130,105 @@ func TestNodeBackendConcurrentReadyAndShutdown(t *testing.T) { nb.Wait(context.Background()) } +func TestPeerCapsIncludesServiceVIPs(t *testing.T) { + nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New()) + + nodeIP := netip.MustParseAddr("100.64.1.1") + svcIP := netip.MustParseAddr("100.124.180.147") + peerIP := netip.MustParseAddr("100.64.2.2") + + // One grant targets the node IP, one targets the service VIP. + mm, err := filter.MatchesFromFilterRules([]tailcfg.FilterRule{ + { + SrcIPs: []string{"100.64.2.0/24"}, + CapGrant: []tailcfg.CapGrant{{ + Dsts: []netip.Prefix{netip.MustParsePrefix("100.64.1.1/32")}, + Caps: []tailcfg.PeerCapability{"node-scoped-cap"}, + }}, + }, + { + SrcIPs: []string{"100.64.2.0/24"}, + CapGrant: []tailcfg.CapGrant{{ + Dsts: []netip.Prefix{netip.MustParsePrefix("100.124.180.147/32")}, + Caps: []tailcfg.PeerCapability{"svc-scoped-cap"}, + CapMap: tailcfg.PeerCapMap{"svc-scoped-cap": {tailcfg.RawMessage(`{"routes":["/test/*"]}`)}}, + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + filt := filter.New(mm, nil, nil, &netipx.IPSet{}, nil, t.Logf) + nb.filterAtomic.Store(filt) + + // Set up the netmap with service VIP mappings. + svcMappings := tailcfg.ServiceIPMappings{ + "svc:http": {svcIP}, + } + svcMappingsJSON, err := json.Marshal(svcMappings) + if err != nil { + t.Fatal(err) + } + + nb.netMap = &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{netip.PrefixFrom(nodeIP, 32)}, + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeAttrServiceHost: {tailcfg.RawMessage(svcMappingsJSON)}, + }, + }).View(), + } + + caps := nb.PeerCaps(peerIP) + if caps == nil { + t.Fatal("PeerCaps returned nil") + } + if !caps.HasCapability("node-scoped-cap") { + t.Error("missing node-scoped-cap") + } + if !caps.HasCapability("svc-scoped-cap") { + t.Error("missing svc-scoped-cap — service VIP caps not included in PeerCaps") + } + + vals := caps["svc-scoped-cap"] + if len(vals) == 0 { + t.Fatal("svc-scoped-cap has no values") + } +} + +func TestPeerCapsNodeOnlyWithoutServiceVIPs(t *testing.T) { + nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New()) + + nodeIP := netip.MustParseAddr("100.64.1.1") + peerIP := netip.MustParseAddr("100.64.2.2") + + mm, err := filter.MatchesFromFilterRules([]tailcfg.FilterRule{ + { + SrcIPs: []string{"100.64.2.0/24"}, + CapGrant: []tailcfg.CapGrant{{ + Dsts: []netip.Prefix{netip.MustParsePrefix("100.64.1.1/32")}, + Caps: []tailcfg.PeerCapability{"basic-cap"}, + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + filt := filter.New(mm, nil, nil, &netipx.IPSet{}, nil, t.Logf) + nb.filterAtomic.Store(filt) + + nb.netMap = &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{netip.PrefixFrom(nodeIP, 32)}, + }).View(), + } + + caps := nb.PeerCaps(peerIP) + if !caps.HasCapability("basic-cap") { + t.Error("missing basic-cap") + } +} + func TestNodeBackendReachability(t *testing.T) { for _, tc := range []struct { name string