From 1b451f8bffaf68ef95b2d8d467fa5dcd5e42bc2d Mon Sep 17 00:00:00 2001 From: Raj Singh Date: Thu, 5 Mar 2026 17:45:39 -0800 Subject: [PATCH] ipn/ipnlocal: include service VIP addresses in PeerCaps resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peerCapsLocked only checked SelfNode.Addresses() when resolving peer capabilities via filter.CapsWithValues. This meant that ACL grants targeting service VIPs (e.g. dst: ["svc:http"]) would never appear in WhoIs CapMap responses, because service VIP addresses are not included in SelfNode.Addresses() — they are delivered separately via the NodeAttrServiceHost capability and AllowedIPs. This affected both the WhoIs LocalAPI endpoint and the built-in ServiceModeHTTP serve layer (addAppCapabilitiesHeader), since both call PeerCaps which delegates to peerCapsLocked. Fix by also iterating service VIP addresses from ServiceIPMappings (delivered via NodeAttrServiceHost) and merging caps from all matching destination addresses. Updates tailscale/corp#38146 Signed-off-by: Raj Singh --- ipn/ipnlocal/node_backend.go | 51 ++++++++++++--- ipn/ipnlocal/node_backend_test.go | 103 ++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 10 deletions(-) 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